Merge branch 'release/0.4.2' into main

This commit is contained in:
Benoit Marty 2024-01-31 10:07:36 +01:00
commit 883f077813
510 changed files with 5596 additions and 1355 deletions

View file

@ -38,7 +38,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.11.1 uses: gradle/gradle-build-action@v2.12.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK - name: Assemble debug APK
@ -47,14 +47,15 @@ jobs:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew :app:assembleDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :app:assembleGplayDebug :app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload APK APKs - name: Upload APK APKs
if: ${{ matrix.variant == 'debug' }} if: ${{ matrix.variant == 'debug' }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: elementx-debug name: elementx-debug
path: | path: |
app/build/outputs/apk/debug/*.apk app/build/outputs/apk/gplay/debug/*.apk
app/build/outputs/apk/fdroid/debug/*.apk
- uses: rnkdsh/action-upload-diawi@v1.5.4 - uses: rnkdsh/action-upload-diawi@v1.5.4
id: diawi id: diawi
# Do not fail the whole build if Diawi upload fails # Do not fail the whole build if Diawi upload fails
@ -64,7 +65,7 @@ jobs:
if: ${{ matrix.variant == 'debug' && github.event_name == 'pull_request' && env.token != '' }} if: ${{ matrix.variant == 'debug' && github.event_name == 'pull_request' && env.token != '' }}
with: with:
token: ${{ env.token }} token: ${{ env.token }}
file: app/build/outputs/apk/debug/app-arm64-v8a-debug.apk file: app/build/outputs/apk/gplay/debug/app-gplay-arm64-v8a-debug.apk
- name: Add or update PR comment with QR Code to download APK. - name: Add or update PR comment with QR Code to download APK.
if: ${{ matrix.variant == 'debug' && 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 uses: NejcZdovc/comment-pr@v2
@ -82,7 +83,7 @@ jobs:
run: ./gradlew compileReleaseSources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES run: ./gradlew compileReleaseSources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Compile nightly sources - name: Compile nightly sources
if: ${{ matrix.variant == 'nightly' }} if: ${{ matrix.variant == 'nightly' }}
run: ./gradlew compileNightlySources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES run: ./gradlew compileGplayNightlySources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Compile samples minimal - name: Compile samples minimal
if: ${{ matrix.variant == 'samples' }} if: ${{ matrix.variant == 'samples' }}
run: ./gradlew :samples:minimal:assemble $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :samples:minimal:assemble $CI_GRADLE_ARG_PROPERTIES

View file

@ -12,4 +12,4 @@ jobs:
# No concurrency required, this is a prerequisite to other actions and should run every time. # No concurrency required, this is a prerequisite to other actions and should run every time.
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: gradle/wrapper-validation-action@v1 - uses: gradle/wrapper-validation-action@v2

View file

@ -53,7 +53,7 @@ jobs:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }} api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
# Doc says (https://github.com/mobile-dev-inc/action-maestro-cloud#android): # Doc says (https://github.com/mobile-dev-inc/action-maestro-cloud#android):
# app-file should point to an x86 compatible APK file, so upload the x86_64 one (much smaller than the universal APK). # app-file should point to an x86 compatible APK file, so upload the x86_64 one (much smaller than the universal APK).
app-file: app/build/outputs/apk/debug/app-x86_64-debug.apk app-file: app/build/outputs/apk/gplay/debug/app-gplay-x86_64-debug.apk
env: | env: |
USERNAME=maestroelement USERNAME=maestroelement
PASSWORD=${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }} PASSWORD=${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }}

View file

@ -33,7 +33,7 @@ jobs:
yes n | towncrier build --version nightly yes n | towncrier build --version nightly
- name: Build and upload Nightly application - name: Build and upload Nightly application
run: | run: |
./gradlew assembleNightly appDistributionUploadNightly $CI_GRADLE_ARG_PROPERTIES ./gradlew assembleGplayNightly appDistributionUploadGplayNightly $CI_GRADLE_ARG_PROPERTIES
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
@ -45,7 +45,7 @@ jobs:
- name: Additionally upload Nightly APK to browserstack for testing - name: Additionally upload Nightly APK to browserstack for testing
continue-on-error: true # don't block anything by this upload failing (for now) continue-on-error: true # don't block anything by this upload failing (for now)
run: | run: |
curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_PASSWORD" -X POST "https://api-cloud.browserstack.com/app-automate/upload" -F "file=@app/build/outputs/apk/nightly/app-universal-nightly.apk" -F "custom_id=element-x-android-nightly" curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_PASSWORD" -X POST "https://api-cloud.browserstack.com/app-automate/upload" -F "file=@app/build/outputs/apk/gplay/nightly/app-gplay-universal-nightly.apk" -F "custom_id=element-x-android-nightly"
env: env:
BROWSERSTACK_USERNAME: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_USERNAME }} BROWSERSTACK_USERNAME: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_USERNAME }}
BROWSERSTACK_PASSWORD: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_ACCESS_KEY }} BROWSERSTACK_PASSWORD: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_ACCESS_KEY }}

View file

@ -62,7 +62,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.11.1 uses: gradle/gradle-build-action@v2.12.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis - name: Dependency analysis

View file

@ -40,7 +40,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.11.1 uses: gradle/gradle-build-action@v2.12.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run code quality check suite - name: Run code quality check suite

View file

@ -39,7 +39,7 @@ jobs:
java-version: '17' java-version: '17'
# Add gradle cache, this should speed up the process # Add gradle cache, this should speed up the process
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.11.1 uses: gradle/gradle-build-action@v2.12.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots - name: Record screenshots

View file

@ -25,16 +25,16 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.11.1 uses: gradle/gradle-build-action@v2.12.0
- name: Create app bundle - name: Create app bundle
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew bundleRelease $CI_GRADLE_ARG_PROPERTIES run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload bundle as artifact - name: Upload bundle as artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: elementx-app-bundle-unsigned name: elementx-app-gplay-bundle-unsigned
path: | path: |
app/build/outputs/bundle/release/app-release.aab app/build/outputs/bundle/gplayRelease/app-gplay-release.aab

View file

@ -32,7 +32,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.11.1 uses: gradle/gradle-build-action@v2.12.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: 🔊 Publish results to Sonar - name: 🔊 Publish results to Sonar

View file

@ -44,7 +44,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.11.1 uses: gradle/gradle-build-action@v2.12.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}

View file

@ -16,6 +16,7 @@
<w>snackbar</w> <w>snackbar</w>
<w>swipeable</w> <w>swipeable</w>
<w>textfields</w> <w>textfields</w>
<w>tombstoned</w>
</words> </words>
</dictionary> </dictionary>
</component> </component>

2
.idea/kotlinc.xml generated
View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.21" /> <option name="version" value="1.9.22" />
</component> </component>
</project> </project>

View file

@ -1,3 +1,37 @@
Changes in Element X v0.4.2 (2024-01-31)
========================================
Matrix SDK 🦀 v0.1.95
Features ✨
----------
- Add 'send private read receipts' option in advanced settings ([#2204](https://github.com/element-hq/element-x-android/issues/2204))
- Send typing notification ([#2240](https://github.com/element-hq/element-x-android/issues/2240)). Disabling the sending of typing notification and rendering typing notification will come soon.
Bugfixes 🐛
----------
- Make the room settings screen update automatically when new room info (name, avatar, topic) is available. ([#921](https://github.com/element-hq/element-x-android/issues/921))
- Update timeline items' read receipts when the room members info is loaded. ([#2176](https://github.com/element-hq/element-x-android/issues/2176))
- Edited text message bubbles should resize when edited ([#2260](https://github.com/element-hq/element-x-android/issues/2260))
- Ensure login and password exclude `\n` ([#2263](https://github.com/element-hq/element-x-android/issues/2263))
- Room list Ensure the indicators stay grey if the global setting is set to mention only and a regular message is received. ([#2282](https://github.com/element-hq/element-x-android/issues/2282))
Other changes
-------------
- Add a special logging configuration for nightlies so we can get more detailed info for existing issues. ([#+add-special-tracing-configuration-for-nightlies](https://github.com/element-hq/element-x-android/issues/+add-special-tracing-configuration-for-nightlies))
- Try mitigating unexpected logouts by making getting/storing session data use a Mutex for synchronization.
Also added some more logs so we can understand exactly where it's failing. ([#+try-mitigating-unexpected-logouts](https://github.com/element-hq/element-x-android/issues/+try-mitigating-unexpected-logouts))
- Upgrade Material3 Compose to `1.2.0-beta02`.
There is also a constraint on a transitive Compose Foundation dependency version (1.6.0-beta02) that fixes the timeline scrolling issue. ([#0-beta02](https://github.com/element-hq/element-x-android/issues/0-beta02))
- Disambiguate display name in the timeline. ([#2215](https://github.com/element-hq/element-x-android/issues/2215))
- Disambiguate display name in notifications ([#2224](https://github.com/element-hq/element-x-android/issues/2224))
- Remove room creation, self-join of room creator and 'this is the beginning of X' timeline items for DMs. ([#2217](https://github.com/element-hq/element-x-android/issues/2217))
- Encrypt databases used by the Rust SDK on Nightly and Debug builds. ([#2219](https://github.com/element-hq/element-x-android/issues/2219))
- Fallback to UnifiedPush (if available) if the PlayServices are not installed on the device. ([#2248](https://github.com/element-hq/element-x-android/issues/2248))
- Add "Report a problem" button to the onboarding screen ([#2275](https://github.com/element-hq/element-x-android/issues/2275))
- Add in app logs viewer to the "Report a problem" screen. ([#2276](https://github.com/element-hq/element-x-android/issues/2276))
Changes in Element X v0.4.1 (2024-01-17) Changes in Element X v0.4.1 (2024-01-17)
======================================== ========================================

View file

@ -20,6 +20,10 @@ import com.android.build.api.variant.FilterConfiguration.FilterType.ABI
import extension.allFeaturesImpl import extension.allFeaturesImpl
import extension.allLibrariesImpl import extension.allLibrariesImpl
import extension.allServicesImpl import extension.allServicesImpl
import extension.gitBranchName
import extension.gitRevision
import extension.koverDependencies
import extension.setupKover
import org.jetbrains.kotlin.cli.common.toBooleanLenient import org.jetbrains.kotlin.cli.common.toBooleanLenient
plugins { plugins {
@ -36,6 +40,8 @@ plugins {
// id("com.google.gms.google-services") // id("com.google.gms.google-services")
} }
setupKover()
android { android {
namespace = "io.element.android.x" namespace = "io.element.android.x"
@ -50,6 +56,9 @@ android {
abiFilters += listOf("armeabi-v7a", "x86", "arm64-v8a", "x86_64") abiFilters += listOf("armeabi-v7a", "x86", "arm64-v8a", "x86_64")
} }
buildConfigField("String", "GIT_REVISION", "\"${gitRevision()}\"")
buildConfigField("String", "GIT_BRANCH_NAME", "\"${gitBranchName()}\"")
// Ref: https://developer.android.com/studio/build/configure-apk-splits.html#configure-abi-split // Ref: https://developer.android.com/studio/build/configure-apk-splits.html#configure-abi-split
splits { splits {
// Configures multiple APKs based on ABI. // Configures multiple APKs based on ABI.
@ -69,7 +78,7 @@ android {
} }
signingConfigs { signingConfigs {
named("debug") { getByName("debug") {
keyAlias = "androiddebugkey" keyAlias = "androiddebugkey"
keyPassword = "android" keyPassword = "android"
storeFile = file("./signature/debug.keystore") storeFile = file("./signature/debug.keystore")
@ -87,13 +96,13 @@ android {
} }
buildTypes { buildTypes {
named("debug") { getByName("debug") {
resValue("string", "app_name", "Element X dbg") resValue("string", "app_name", "Element X dbg")
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
} }
named("release") { getByName("release") {
resValue("string", "app_name", "Element X") resValue("string", "app_name", "Element X")
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
@ -124,7 +133,7 @@ android {
// We upload the universal APK to fix this error: // We upload the universal APK to fix this error:
// "App Distribution found more than 1 output file for this variant. // "App Distribution found more than 1 output file for this variant.
// Please contact firebase-support@google.com for help using APK splits with App Distribution." // Please contact firebase-support@google.com for help using APK splits with App Distribution."
artifactPath = "$rootDir/app/build/outputs/apk/nightly/app-universal-nightly.apk" artifactPath = "$rootDir/app/build/outputs/apk/gplay/nightly/app-gplay-universal-nightly.apk"
// artifactType = "AAB" // artifactType = "AAB"
// artifactPath = "$rootDir/app/build/outputs/bundle/nightly/app-nightly.aab" // artifactPath = "$rootDir/app/build/outputs/bundle/nightly/app-nightly.aab"
// This file will be generated by the GitHub action // This file will be generated by the GitHub action
@ -143,6 +152,20 @@ android {
buildFeatures { buildFeatures {
buildConfig = true buildConfig = true
} }
flavorDimensions += "store"
productFlavors {
create("gplay") {
dimension = "store"
isDefault = true
buildConfigField("String", "SHORT_FLAVOR_DESCRIPTION", "\"G\"")
buildConfigField("String", "FLAVOR_DESCRIPTION", "\"GooglePlay\"")
}
create("fdroid") {
dimension = "store"
buildConfigField("String", "SHORT_FLAVOR_DESCRIPTION", "\"F\"")
buildConfigField("String", "FLAVOR_DESCRIPTION", "\"FDroid\"")
}
}
} }
androidComponents { androidComponents {
@ -222,6 +245,11 @@ dependencies {
implementation(projects.appconfig) implementation(projects.appconfig)
anvil(projects.anvilcodegen) anvil(projects.anvilcodegen)
// Comment to not include firebase in the project
"gplayImplementation"(projects.libraries.pushproviders.firebase)
// Comment to not include unified push in the project
implementation(projects.libraries.pushproviders.unifiedpush)
implementation(libs.appyx.core) implementation(libs.appyx.core)
implementation(libs.androidx.splash) implementation(libs.androidx.splash)
implementation(libs.androidx.core) implementation(libs.androidx.core)
@ -251,4 +279,5 @@ dependencies {
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
koverDependencies()
} }

View file

@ -18,7 +18,7 @@ package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.preferences.api.store.PreferencesStore import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
@ -34,5 +34,5 @@ interface AppBindings {
fun lockScreenService(): LockScreenService fun lockScreenService(): LockScreenService
fun preferencesStore(): PreferencesStore fun preferencesStore(): AppPreferencesStore
} }

View file

@ -85,16 +85,10 @@ object AppModule {
lowPrivacyLoggingEnabled = false, lowPrivacyLoggingEnabled = false,
versionName = BuildConfig.VERSION_NAME, versionName = BuildConfig.VERSION_NAME,
versionCode = BuildConfig.VERSION_CODE, versionCode = BuildConfig.VERSION_CODE,
// BuildConfig.GIT_REVISION, gitRevision = BuildConfig.GIT_REVISION,
gitRevision = "TODO", gitBranchName = BuildConfig.GIT_BRANCH_NAME,
// BuildConfig.GIT_REVISION_DATE, flavorDescription = BuildConfig.FLAVOR_DESCRIPTION,
gitRevisionDate = "TODO", flavorShortDescription = BuildConfig.SHORT_FLAVOR_DESCRIPTION,
// BuildConfig.GIT_BRANCH_NAME,
gitBranchName = "TODO",
// BuildConfig.FLAVOR_DESCRIPTION,
flavorDescription = "TODO",
// BuildConfig.SHORT_FLAVOR_DESCRIPTION,
flavorShortDescription = "TODO",
) )
@Provides @Provides

View file

@ -33,6 +33,7 @@ interface SessionComponent : NodeFactoriesBindings {
interface Builder { interface Builder {
@BindsInstance @BindsInstance
fun client(matrixClient: MatrixClient): Builder fun client(matrixClient: MatrixClient): Builder
fun build(): SessionComponent fun build(): SessionComponent
} }

View file

@ -29,6 +29,8 @@ fun logApplicationInfo() {
append(BuildConfig.VERSION_CODE) append(BuildConfig.VERSION_CODE)
append(") - ") append(") - ")
append(BuildConfig.BUILD_TYPE) append(BuildConfig.BUILD_TYPE)
append(" / ")
append(BuildConfig.FLAVOR)
} }
// TODO Get SDK version somehow // TODO Get SDK version somehow
val sdkVersion = "SDK VERSION (TODO)" val sdkVersion = "SDK VERSION (TODO)"
@ -37,6 +39,7 @@ fun logApplicationInfo() {
Timber.d("----------------------------------------------------------------") Timber.d("----------------------------------------------------------------")
Timber.d("----------------------------------------------------------------") Timber.d("----------------------------------------------------------------")
Timber.d(" Application version: $appVersion") Timber.d(" Application version: $appVersion")
Timber.d(" Git SHA: ${BuildConfig.GIT_REVISION}")
Timber.d(" SDK version: $sdkVersion") Timber.d(" SDK version: $sdkVersion")
Timber.d(" Local time: $date") Timber.d(" Local time: $date")
Timber.d("----------------------------------------------------------------") Timber.d("----------------------------------------------------------------")

View file

@ -46,8 +46,13 @@ class TracingInitializer : Initializer<Unit> {
writesToFilesConfiguration = WriteToFilesConfiguration.Disabled writesToFilesConfiguration = WriteToFilesConfiguration.Disabled
) )
} else { } else {
val config = if (BuildConfig.BUILD_TYPE == "nightly") {
TracingFilterConfigurations.nightly
} else {
TracingFilterConfigurations.release
}
TracingConfiguration( TracingConfiguration(
filterConfiguration = TracingFilterConfigurations.release, filterConfiguration = config,
writesToLogcat = false, writesToLogcat = false,
writesToFilesConfiguration = WriteToFilesConfiguration.Enabled( writesToFilesConfiguration = WriteToFilesConfiguration.Enabled(
directory = bugReporter.logDirectory().absolutePath, directory = bugReporter.logDirectory().absolutePath,

View file

@ -52,6 +52,7 @@ dependencies {
implementation(libs.coil) implementation(libs.coil)
implementation(projects.features.ftue.api) implementation(projects.features.ftue.api)
implementation(projects.features.viewfolder.api)
implementation(projects.services.apperror.impl) implementation(projects.services.apperror.impl)
implementation(projects.services.appnavstate.api) implementation(projects.services.appnavstate.api)

View file

@ -24,6 +24,7 @@ import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted import dagger.assisted.Assisted
@ -54,6 +55,10 @@ class NotLoggedInFlowNode @AssistedInject constructor(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
) { ) {
interface Callback : Plugin {
fun onOpenBugReport()
}
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
lifecycle.subscribe( lifecycle.subscribe(
@ -91,6 +96,10 @@ class NotLoggedInFlowNode @AssistedInject constructor(
override fun onOpenDeveloperSettings() { override fun onOpenDeveloperSettings() {
backstack.push(NavTarget.ConfigureTracing) backstack.push(NavTarget.ConfigureTracing)
} }
override fun onReportProblem() {
plugins<Callback>().forEach { it.onOpenBugReport() }
}
} }
onBoardingEntryPoint onBoardingEntryPoint
.nodeBuilder(this, buildContext) .nodeBuilder(this, buildContext)

View file

@ -45,6 +45,7 @@ import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.api.oidc.OidcActionFlow import io.element.android.features.login.api.oidc.OidcActionFlow
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.features.signedout.api.SignedOutEntryPoint import io.element.android.features.signedout.api.SignedOutEntryPoint
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
@ -70,6 +71,7 @@ class RootFlowNode @AssistedInject constructor(
private val matrixClientsHolder: MatrixClientsHolder, private val matrixClientsHolder: MatrixClientsHolder,
private val presenter: RootPresenter, private val presenter: RootPresenter,
private val bugReportEntryPoint: BugReportEntryPoint, private val bugReportEntryPoint: BugReportEntryPoint,
private val viewFolderEntryPoint: ViewFolderEntryPoint,
private val signedOutEntryPoint: SignedOutEntryPoint, private val signedOutEntryPoint: SignedOutEntryPoint,
private val intentResolver: IntentResolver, private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow, private val oidcActionFlow: OidcActionFlow,
@ -141,7 +143,7 @@ class RootFlowNode @AssistedInject constructor(
onSuccess(sessionId) onSuccess(sessionId)
} }
.onFailure { .onFailure {
Timber.v("Failed to restore session $sessionId") Timber.e(it, "Failed to restore session $sessionId")
onFailure() onFailure()
} }
} }
@ -194,6 +196,11 @@ class RootFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object BugReport : NavTarget data object BugReport : NavTarget
@Parcelize
data class ViewLogs(
val rootPath: String,
) : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -210,7 +217,14 @@ class RootFlowNode @AssistedInject constructor(
} }
createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback)) createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback))
} }
NavTarget.NotLoggedInFlow -> createNode<NotLoggedInFlowNode>(buildContext) NavTarget.NotLoggedInFlow -> {
val callback = object : NotLoggedInFlowNode.Callback {
override fun onOpenBugReport() {
backstack.push(NavTarget.BugReport)
}
}
createNode<NotLoggedInFlowNode>(buildContext, plugins = listOf(callback))
}
is NavTarget.SignedOutFlow -> { is NavTarget.SignedOutFlow -> {
signedOutEntryPoint.nodeBuilder(this, buildContext) signedOutEntryPoint.nodeBuilder(this, buildContext)
.params( .params(
@ -226,12 +240,31 @@ class RootFlowNode @AssistedInject constructor(
override fun onBugReportSent() { override fun onBugReportSent() {
backstack.pop() backstack.pop()
} }
override fun onViewLogs(basePath: String) {
backstack.push(NavTarget.ViewLogs(rootPath = basePath))
}
} }
bugReportEntryPoint bugReportEntryPoint
.nodeBuilder(this, buildContext) .nodeBuilder(this, buildContext)
.callback(callback) .callback(callback)
.build() .build()
} }
is NavTarget.ViewLogs -> {
val callback = object : ViewFolderEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}
}
val params = ViewFolderEntryPoint.Params(
rootPath = navTarget.rootPath,
)
viewFolderEntryPoint
.nodeBuilder(this, buildContext)
.params(params)
.callback(callback)
.build()
}
} }
} }

View file

@ -118,14 +118,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
} }
private fun fetchRoomMembers() = lifecycleScope.launch { private fun fetchRoomMembers() = lifecycleScope.launch {
val room = inputs.room inputs.room.updateMembers()
room.updateMembers()
.onFailure {
Timber.e(it, "Fail to fetch members for room ${room.roomId}")
}
.onSuccess {
Timber.v("Success fetching members for room ${room.roomId}")
}
} }
private fun createRoomDetailsNode(buildContext: BuildContext, initialTarget: RoomDetailsEntryPoint.InitialTarget): Node { private fun createRoomDetailsNode(buildContext: BuildContext, initialTarget: RoomDetailsEntryPoint.InitialTarget): Node {

View file

@ -60,7 +60,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml")) config.from(files("$rootDir/tools/detekt/detekt.yml"))
} }
dependencies { dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.3.9") detektPlugins("io.nlopez.compose.rules:detekt:0.3.11")
} }
// KtLint // KtLint

View file

@ -0,0 +1,2 @@
Main changes in this version: be able to send a problem from the first screen, and add an internal log viewer. Be able to send private read receipt, send typing notification, improve performance.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -45,7 +45,7 @@ import io.element.android.features.call.CallForegroundService
import io.element.android.features.call.CallType import io.element.android.features.call.CallType
import io.element.android.features.call.di.CallBindings import io.element.android.features.call.di.CallBindings
import io.element.android.features.call.utils.CallIntentDataParser import io.element.android.features.call.utils.CallIntentDataParser
import io.element.android.features.preferences.api.store.PreferencesStore import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import javax.inject.Inject import javax.inject.Inject
@ -67,7 +67,7 @@ class ElementCallActivity : NodeComponentActivity(), CallScreenNavigator {
@Inject lateinit var callIntentDataParser: CallIntentDataParser @Inject lateinit var callIntentDataParser: CallIntentDataParser
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory @Inject lateinit var presenterFactory: CallScreenPresenter.Factory
@Inject lateinit var preferencesStore: PreferencesStore @Inject lateinit var appPreferencesStore: AppPreferencesStore
private lateinit var presenter: CallScreenPresenter private lateinit var presenter: CallScreenPresenter
@ -101,7 +101,7 @@ class ElementCallActivity : NodeComponentActivity(), CallScreenNavigator {
setContent { setContent {
val theme by remember { val theme by remember {
preferencesStore.getThemeFlow().mapToTheme() appPreferencesStore.getThemeFlow().mapToTheme()
} }
.collectAsState(initial = Theme.System) .collectAsState(initial = Theme.System)
val state = presenter.present() val state = presenter.present()

View file

@ -18,7 +18,7 @@ package io.element.android.features.call.utils
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.ElementCallConfig import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.preferences.api.store.PreferencesStore import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -31,7 +31,7 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultCallWidgetProvider @Inject constructor( class DefaultCallWidgetProvider @Inject constructor(
private val matrixClientsProvider: MatrixClientProvider, private val matrixClientsProvider: MatrixClientProvider,
private val preferencesStore: PreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val callWidgetSettingsProvider: CallWidgetSettingsProvider, private val callWidgetSettingsProvider: CallWidgetSettingsProvider,
) : CallWidgetProvider { ) : CallWidgetProvider {
override suspend fun getWidget( override suspend fun getWidget(
@ -42,7 +42,7 @@ class DefaultCallWidgetProvider @Inject constructor(
theme: String?, theme: String?,
): Result<Pair<MatrixWidgetDriver, String>> = runCatching { ): Result<Pair<MatrixWidgetDriver, String>> = runCatching {
val room = matrixClientsProvider.getOrRestore(sessionId).getOrThrow().getRoom(roomId) ?: error("Room not found") val room = matrixClientsProvider.getOrRestore(sessionId).getOrThrow().getRoom(roomId) ?: error("Room not found")
val baseUrl = preferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull() ?: ElementCallConfig.DEFAULT_BASE_URL val baseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull() ?: ElementCallConfig.DEFAULT_BASE_URL
val widgetSettings = callWidgetSettingsProvider.provide(baseUrl) val widgetSettings = callWidgetSettingsProvider.provide(baseUrl)
val callUrl = room.generateWidgetWebViewUrl(widgetSettings, clientId, languageTag, theme).getOrThrow() val callUrl = room.generateWidgetWebViewUrl(widgetSettings, clientId, languageTag, theme).getOrThrow()
room.getWidgetDriver(widgetSettings).getOrThrow() to callUrl room.getWidgetDriver(widgetSettings).getOrThrow() to callUrl

View file

@ -17,8 +17,8 @@
package io.element.android.features.call.utils package io.element.android.features.call.utils
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.preferences.api.store.PreferencesStore import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore import io.element.android.libraries.featureflag.test.InMemoryAppPreferencesStore
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
@ -94,14 +94,14 @@ class DefaultCallWidgetProviderTest {
val client = FakeMatrixClient().apply { val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room) givenGetRoomResult(A_ROOM_ID, room)
} }
val preferencesStore = InMemoryPreferencesStore().apply { val preferencesStore = InMemoryAppPreferencesStore().apply {
setCustomElementCallBaseUrl("https://custom.element.io") setCustomElementCallBaseUrl("https://custom.element.io")
} }
val settingsProvider = FakeCallWidgetSettingsProvider() val settingsProvider = FakeCallWidgetSettingsProvider()
val provider = createProvider( val provider = createProvider(
matrixClientProvider = FakeMatrixClientProvider { Result.success(client) }, matrixClientProvider = FakeMatrixClientProvider { Result.success(client) },
callWidgetSettingsProvider = settingsProvider, callWidgetSettingsProvider = settingsProvider,
preferencesStore = preferencesStore, appPreferencesStore = preferencesStore,
) )
provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme") provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme")
@ -110,11 +110,11 @@ class DefaultCallWidgetProviderTest {
private fun createProvider( private fun createProvider(
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
preferencesStore: PreferencesStore = InMemoryPreferencesStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider() callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider()
) = DefaultCallWidgetProvider( ) = DefaultCallWidgetProvider(
matrixClientProvider, matrixClientProvider,
preferencesStore, appPreferencesStore,
callWidgetSettingsProvider, callWidgetSettingsProvider,
) )
} }

View file

@ -23,10 +23,11 @@ import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider 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.CheckableUserRow
import io.element.android.libraries.matrix.ui.components.CheckableUnresolvedUserRow import io.element.android.libraries.matrix.ui.components.CheckableUserRowData
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.usersearch.api.UserSearchResult import io.element.android.libraries.usersearch.api.UserSearchResult
@Composable @Composable
@ -36,23 +37,24 @@ fun SearchMultipleUsersResultItem(
onCheckedChange: (Boolean) -> Unit, onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (searchResult.isUnresolved) { val data = if (searchResult.isUnresolved) {
CheckableUnresolvedUserRow( CheckableUserRowData.Unresolved(
checked = isUserSelected,
modifier = modifier,
avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.UserListItem), avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.UserListItem),
id = searchResult.matrixUser.userId.value, id = searchResult.matrixUser.userId.value,
onCheckedChange = onCheckedChange,
) )
} else { } else {
CheckableMatrixUserRow( CheckableUserRowData.Resolved(
checked = isUserSelected, name = searchResult.matrixUser.getBestName(),
modifier = modifier, subtext = if (searchResult.matrixUser.displayName.isNullOrEmpty()) null else searchResult.matrixUser.userId.value,
matrixUser = searchResult.matrixUser, avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.UserListItem),
avatarSize = AvatarSize.UserListItem,
onCheckedChange = onCheckedChange,
) )
} }
CheckableUserRow(
checked = isUserSelected,
modifier = modifier,
data = data,
onCheckedChange = onCheckedChange,
)
} }
@Preview @Preview

View file

@ -18,6 +18,8 @@ package io.element.android.features.ftue.impl.migration
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import io.element.android.features.ftue.impl.R import io.element.android.features.ftue.impl.R
@ -32,8 +34,9 @@ fun MigrationScreenView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (migrationState.isMigrating.not()) { if (migrationState.isMigrating.not()) {
val latestOnMigrationFinished by rememberUpdatedState(onMigrationFinished)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
onMigrationFinished() latestOnMigrationFinished()
} }
} }
SunsetPage( SunsetPage(

View file

@ -169,7 +169,7 @@ class InviteListPresenter @Inject constructor(
AvatarData( AvatarData(
id = roomId.value, id = roomId.value,
name = name, name = name,
url = avatarURLString, url = avatarUrl,
size = AvatarSize.RoomInviteItem, size = AvatarSize.RoomInviteItem,
) )
} }

View file

@ -28,6 +28,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -57,8 +59,9 @@ fun InviteListView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (state.acceptedAction is AsyncData.Success) { if (state.acceptedAction is AsyncData.Success) {
val latestOnInviteAccepted by rememberUpdatedState(onInviteAccepted)
LaunchedEffect(state.acceptedAction) { LaunchedEffect(state.acceptedAction) {
onInviteAccepted(state.acceptedAction.data) latestOnInviteAccepted(state.acceptedAction.data)
} }
} }

View file

@ -30,7 +30,6 @@ 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.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.roomlist.RoomSummary 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.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_ROOM_ID 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.A_ROOM_ID_2
@ -39,6 +38,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.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient 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.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager
@ -425,14 +425,12 @@ class InviteListPresenterTests {
postInviteRooms( postInviteRooms(
listOf( listOf(
RoomSummary.Filled( RoomSummary.Filled(
RoomSummaryDetails( aRoomSummaryDetails(
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
name = A_ROOM_NAME, name = A_ROOM_NAME,
avatarURLString = null, avatarUrl = null,
isDirect = false, isDirect = false,
lastMessage = null, lastMessage = null,
lastMessageTimestamp = null,
unreadNotificationCount = 0,
inviter = RoomMember( inviter = RoomMember(
userId = A_USER_ID, userId = A_USER_ID,
displayName = A_USER_NAME, displayName = A_USER_NAME,
@ -454,14 +452,12 @@ class InviteListPresenterTests {
postInviteRooms( postInviteRooms(
listOf( listOf(
RoomSummary.Filled( RoomSummary.Filled(
RoomSummaryDetails( aRoomSummaryDetails(
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
name = A_ROOM_NAME, name = A_ROOM_NAME,
avatarURLString = null, avatarUrl = null,
isDirect = true, isDirect = true,
lastMessage = null, lastMessage = null,
lastMessageTimestamp = null,
unreadNotificationCount = 0,
inviter = RoomMember( inviter = RoomMember(
userId = A_USER_ID, userId = A_USER_ID,
displayName = A_USER_NAME, displayName = A_USER_NAME,
@ -480,14 +476,12 @@ class InviteListPresenterTests {
} }
private fun aRoomSummary(id: RoomId = A_ROOM_ID) = RoomSummary.Filled( private fun aRoomSummary(id: RoomId = A_ROOM_ID) = RoomSummary.Filled(
RoomSummaryDetails( aRoomSummaryDetails(
roomId = id, roomId = id,
name = A_ROOM_NAME, name = A_ROOM_NAME,
avatarURLString = null, avatarUrl = null,
isDirect = false, isDirect = false,
lastMessage = null, lastMessage = null,
lastMessageTimestamp = null,
unreadNotificationCount = 0,
) )
) )

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_conversation_alert_subtitle">"Opravdu chcete opustit tuto konverzaci? Tato konverzace není veřejná a bez pozvánky se k ní nebudete moci znovu připojit."</string>
<string name="leave_room_alert_empty_subtitle">"Opravdu chcete opustit tuto místnost? Jste tu jediná osoba. Pokud odejdete, nikdo se v budoucnu nebude moci připojit, včetně vás."</string> <string name="leave_room_alert_empty_subtitle">"Opravdu chcete opustit tuto místnost? Jste tu jediná osoba. Pokud odejdete, nikdo se v budoucnu nebude moci připojit, včetně vás."</string>
<string name="leave_room_alert_private_subtitle">"Opravdu chcete opustit tuto místnost? Tato místnost není veřejná a bez pozvánky se nebudete moci znovu připojit."</string> <string name="leave_room_alert_private_subtitle">"Opravdu chcete opustit tuto místnost? Tato místnost není veřejná a bez pozvánky se nebudete moci znovu připojit."</string>
<string name="leave_room_alert_subtitle">"Opravdu chcete opustit místnost?"</string> <string name="leave_room_alert_subtitle">"Opravdu chcete opustit místnost?"</string>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_conversation_alert_subtitle">"Êtes-vous sûr de vouloir quitter cette discussion? Vous ne pourrez pas la rejoindre à nouveau sans y être invité."</string>
<string name="leave_room_alert_empty_subtitle">"Êtes-vous sûr de vouloir quitter ce salon ? Vous êtes la seule personne ici. Si vous partez, personne ne pourra rejoindre le salon à lavenir, y compris vous."</string> <string name="leave_room_alert_empty_subtitle">"Êtes-vous sûr de vouloir quitter ce salon ? Vous êtes la seule personne ici. Si vous partez, personne ne pourra rejoindre le salon à lavenir, y compris vous."</string>
<string name="leave_room_alert_private_subtitle">"Êtes-vous sûr de vouloir quitter ce salon ? Ce salon nest pas public et vous ne pourrez pas le rejoindre sans invitation."</string> <string name="leave_room_alert_private_subtitle">"Êtes-vous sûr de vouloir quitter ce salon ? Ce salon nest pas public et vous ne pourrez pas le rejoindre sans invitation."</string>
<string name="leave_room_alert_subtitle">"Êtes-vous sûr de vouloir quitter le salon ?"</string> <string name="leave_room_alert_subtitle">"Êtes-vous sûr de vouloir quitter le salon ?"</string>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_conversation_alert_subtitle">"Vuoi davvero abbandonare questa conversazione? La conversazione non è pubblica e non potrai rientrare senza un invito."</string>
<string name="leave_room_alert_empty_subtitle">"Sei sicuro di voler lasciare questa stanza? Sei l\'unica persona presente. Se esci, nessuno potrà unirsi in futuro, te compreso."</string> <string name="leave_room_alert_empty_subtitle">"Sei sicuro di voler lasciare questa stanza? Sei l\'unica persona presente. Se esci, nessuno potrà unirsi in futuro, te compreso."</string>
<string name="leave_room_alert_private_subtitle">"Sei sicuro di voler lasciare questa stanza? Questa stanza non è pubblica e non potrai rientrare senza un invito."</string> <string name="leave_room_alert_private_subtitle">"Sei sicuro di voler lasciare questa stanza? Questa stanza non è pubblica e non potrai rientrare senza un invito."</string>
<string name="leave_room_alert_subtitle">"Sei sicuro di voler lasciare la stanza?"</string> <string name="leave_room_alert_subtitle">"Sei sicuro di voler lasciare la stanza?"</string>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_conversation_alert_subtitle">"Вы уверены, что хотите покинуть беседу?"</string>
<string name="leave_room_alert_empty_subtitle">"Вы уверены, что хотите покинуть эту комнату? Вы здесь единственный человек. Если вы уйдете, никто не сможет присоединиться в будущем, включая вас."</string> <string name="leave_room_alert_empty_subtitle">"Вы уверены, что хотите покинуть эту комнату? Вы здесь единственный человек. Если вы уйдете, никто не сможет присоединиться в будущем, включая вас."</string>
<string name="leave_room_alert_private_subtitle">"Вы уверены, что хотите покинуть эту комнату? Эта комната не является публичной, и Вы не сможете присоединиться к ней без приглашения."</string> <string name="leave_room_alert_private_subtitle">"Вы уверены, что хотите покинуть эту комнату? Эта комната не является публичной, и Вы не сможете присоединиться к ней без приглашения."</string>
<string name="leave_room_alert_subtitle">"Вы уверены, что хотите покинуть комнату?"</string> <string name="leave_room_alert_subtitle">"Вы уверены, что хотите покинуть комнату?"</string>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_conversation_alert_subtitle">"Ste si istí, že chcete opustiť konverzáciu?"</string>
<string name="leave_room_alert_empty_subtitle">"Ste si istí, že chcete opustiť túto miestnosť? Ste tu jediná osoba. Ak odídete, nikto sa do nej nebude môcť v budúcnosti pripojiť, vrátane vás."</string> <string name="leave_room_alert_empty_subtitle">"Ste si istí, že chcete opustiť túto miestnosť? Ste tu jediná osoba. Ak odídete, nikto sa do nej nebude môcť v budúcnosti pripojiť, vrátane vás."</string>
<string name="leave_room_alert_private_subtitle">"Ste si istí, že chcete opustiť túto miestnosť? Táto miestnosť nie je verejná a bez pozvania sa do nej nebudete môcť vrátiť."</string> <string name="leave_room_alert_private_subtitle">"Ste si istí, že chcete opustiť túto miestnosť? Táto miestnosť nie je verejná a bez pozvania sa do nej nebudete môcť vrátiť."</string>
<string name="leave_room_alert_subtitle">"Ste si istí, že chcete opustiť miestnosť?"</string> <string name="leave_room_alert_subtitle">"Ste si istí, že chcete opustiť miestnosť?"</string>

View file

@ -18,6 +18,8 @@ package io.element.android.features.lockscreen.impl.unlock
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
import io.element.android.features.lockscreen.impl.biometric.DefaultBiometricUnlockCallback import io.element.android.features.lockscreen.impl.biometric.DefaultBiometricUnlockCallback
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
@ -30,15 +32,16 @@ class PinUnlockHelper @Inject constructor(
) { ) {
@Composable @Composable
fun OnUnlockEffect(onUnlock: () -> Unit) { fun OnUnlockEffect(onUnlock: () -> Unit) {
val latestOnUnlock by rememberUpdatedState(onUnlock)
DisposableEffect(Unit) { DisposableEffect(Unit) {
val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() { val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() {
override fun onBiometricUnlockSuccess() { override fun onBiometricUnlockSuccess() {
onUnlock() latestOnUnlock()
} }
} }
val pinCodeVerifiedCallback = object : DefaultPinCodeManagerCallback() { val pinCodeVerifiedCallback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeVerified() { override fun onPinCodeVerified() {
onUnlock() latestOnUnlock()
} }
} }
biometricUnlockManager.addCallback(biometricUnlockCallback) biometricUnlockManager.addCallback(biometricUnlockCallback)

View file

@ -18,6 +18,8 @@ package io.element.android.features.login.impl.changeserver
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
@ -63,8 +65,11 @@ fun ChangeServerView(
} }
} }
is AsyncData.Loading -> ProgressDialog() is AsyncData.Loading -> ProgressDialog()
is AsyncData.Success -> LaunchedEffect(state.changeServerAction) { is AsyncData.Success -> {
onDone() val latestOnDone by rememberUpdatedState(onDone)
LaunchedEffect(state.changeServerAction) {
latestOnDone()
}
} }
AsyncData.Uninitialized -> Unit AsyncData.Uninitialized -> Unit
} }

View file

@ -194,15 +194,17 @@ private fun LoginForm(
.onTabOrEnterKeyFocusNext(focusManager) .onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginEmailUsername) .testTag(TestTags.loginEmailUsername)
.autofill(autofillTypes = listOf(AutofillType.Username), onFill = { .autofill(autofillTypes = listOf(AutofillType.Username), onFill = {
loginFieldState = it val sanitized = it.sanitize()
eventSink(LoginPasswordEvents.SetLogin(it)) loginFieldState = sanitized
eventSink(LoginPasswordEvents.SetLogin(sanitized))
}), }),
placeholder = { placeholder = {
Text(text = stringResource(CommonStrings.common_username)) Text(text = stringResource(CommonStrings.common_username))
}, },
onValueChange = { onValueChange = {
loginFieldState = it val sanitized = it.sanitize()
eventSink(LoginPasswordEvents.SetLogin(it)) loginFieldState = sanitized
eventSink(LoginPasswordEvents.SetLogin(sanitized))
}, },
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email, keyboardType = KeyboardType.Email,
@ -224,7 +226,6 @@ private fun LoginForm(
null null
}, },
) )
var passwordVisible by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) }
if (state.loginAction is AsyncData.Loading) { if (state.loginAction is AsyncData.Loading) {
// Ensure password is hidden when user submits the form // Ensure password is hidden when user submits the form
@ -239,12 +240,14 @@ private fun LoginForm(
.onTabOrEnterKeyFocusNext(focusManager) .onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginPassword) .testTag(TestTags.loginPassword)
.autofill(autofillTypes = listOf(AutofillType.Password), onFill = { .autofill(autofillTypes = listOf(AutofillType.Password), onFill = {
passwordFieldState = it val sanitized = it.sanitize()
eventSink(LoginPasswordEvents.SetPassword(it)) passwordFieldState = sanitized
eventSink(LoginPasswordEvents.SetPassword(sanitized))
}), }),
onValueChange = { onValueChange = {
passwordFieldState = it val sanitized = it.sanitize()
eventSink(LoginPasswordEvents.SetPassword(it)) passwordFieldState = sanitized
eventSink(LoginPasswordEvents.SetPassword(sanitized))
}, },
placeholder = { placeholder = {
Text(text = stringResource(CommonStrings.common_password)) Text(text = stringResource(CommonStrings.common_password))
@ -272,6 +275,13 @@ private fun LoginForm(
} }
} }
/**
* Ensure that the string does not contain any new line characters, which can happen when pasting values.
*/
private fun String.sanitize(): String {
return replace("\n", "")
}
@Composable @Composable
private fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) { private fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
ErrorDialog( ErrorDialog(

View file

@ -18,6 +18,8 @@ package io.element.android.features.logout.impl.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import io.element.android.features.logout.impl.R import io.element.android.features.logout.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
@ -52,9 +54,11 @@ fun LogoutActionDialog(
onRetry = onForceLogoutClicked, onRetry = onForceLogoutClicked,
onDismiss = onDismissError, onDismiss = onDismissError,
) )
is AsyncAction.Success -> is AsyncAction.Success -> {
val latestOnSuccessLogout by rememberUpdatedState(onSuccessLogout)
LaunchedEffect(state) { LaunchedEffect(state) {
onSuccessLogout(state.data) latestOnSuccessLogout(state.data)
} }
}
} }
} }

View file

@ -63,7 +63,7 @@ import io.element.android.features.messages.impl.utils.messagesummary.MessageSum
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.store.PreferencesStore import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -81,10 +81,10 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo 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.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.room.canRedactAsState import io.element.android.libraries.matrix.ui.room.canRedactOtherAsState
import io.element.android.libraries.matrix.ui.room.canRedactOwnAsState
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -107,12 +107,11 @@ class MessagesPresenter @AssistedInject constructor(
private val messageSummaryFormatter: MessageSummaryFormatter, private val messageSummaryFormatter: MessageSummaryFormatter,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val clipboardHelper: ClipboardHelper, private val clipboardHelper: ClipboardHelper,
private val preferencesStore: PreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val featureFlagsService: FeatureFlagService, private val featureFlagsService: FeatureFlagService,
private val htmlConverterProvider: HtmlConverterProvider, private val htmlConverterProvider: HtmlConverterProvider,
@Assisted private val navigator: MessagesNavigator, @Assisted private val navigator: MessagesNavigator,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val currentSessionIdHolder: CurrentSessionIdHolder,
) : Presenter<MessagesState> { ) : Presenter<MessagesState> {
private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator) private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator)
@ -123,7 +122,7 @@ class MessagesPresenter @AssistedInject constructor(
@Composable @Composable
override fun present(): MessagesState { override fun present(): MessagesState {
htmlConverterProvider.Update(currentUserId = currentSessionIdHolder.current) htmlConverterProvider.Update(currentUserId = room.sessionId)
val roomInfo by room.roomInfoFlow.collectAsState(null) val roomInfo by room.roomInfoFlow.collectAsState(null)
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
@ -138,7 +137,8 @@ class MessagesPresenter @AssistedInject constructor(
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value) val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
val userHasPermissionToRedact by room.canRedactAsState(updateKey = syncUpdateFlow.value) val userHasPermissionToRedactOwn by room.canRedactOwnAsState(updateKey = syncUpdateFlow.value)
val userHasPermissionToRedactOther by room.canRedactOtherAsState(updateKey = syncUpdateFlow.value)
val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION_SENT, updateKey = syncUpdateFlow.value) val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION_SENT, updateKey = syncUpdateFlow.value)
val roomName: AsyncData<String> by remember { val roomName: AsyncData<String> by remember {
derivedStateOf { roomInfo?.name?.let { AsyncData.Success(it) } ?: AsyncData.Uninitialized } derivedStateOf { roomInfo?.name?.let { AsyncData.Success(it) } ?: AsyncData.Uninitialized }
@ -155,15 +155,15 @@ class MessagesPresenter @AssistedInject constructor(
mutableStateOf(false) mutableStateOf(false)
} }
LaunchedEffect(currentSessionIdHolder.current) { LaunchedEffect(syncUpdateFlow.value) {
withContext(dispatchers.io) { withContext(dispatchers.io) {
canJoinCall = room.canUserJoinCall(userId = currentSessionIdHolder.current).getOrDefault(false) canJoinCall = room.canUserJoinCall(room.sessionId).getOrDefault(false)
} }
} }
val inviteProgress = remember { mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized) } val inviteProgress = remember { mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized) }
var showReinvitePrompt by remember { mutableStateOf(false) } var showReinvitePrompt by remember { mutableStateOf(false) }
LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow) { LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow.value) {
withContext(dispatchers.io) { withContext(dispatchers.io) {
showReinvitePrompt = !hasDismissedInviteDialog && composerState.hasFocus && room.isDirect && room.activeMemberCount == 1L showReinvitePrompt = !hasDismissedInviteDialog && composerState.hasFocus && room.isDirect && room.activeMemberCount == 1L
} }
@ -176,7 +176,7 @@ class MessagesPresenter @AssistedInject constructor(
timelineState.eventSink(TimelineEvents.SetHighlightedEvent(composerState.mode.relatedEventId)) timelineState.eventSink(TimelineEvents.SetHighlightedEvent(composerState.mode.relatedEventId))
} }
val enableTextFormatting by preferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true) val enableTextFormatting by appPreferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
var enableVoiceMessages by remember { mutableStateOf(false) } var enableVoiceMessages by remember { mutableStateOf(false) }
LaunchedEffect(featureFlagsService) { LaunchedEffect(featureFlagsService) {
@ -219,7 +219,8 @@ class MessagesPresenter @AssistedInject constructor(
roomName = roomName, roomName = roomName,
roomAvatar = roomAvatar, roomAvatar = roomAvatar,
userHasPermissionToSendMessage = userHasPermissionToSendMessage, userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToRedact = userHasPermissionToRedact, userHasPermissionToRedactOwn = userHasPermissionToRedactOwn,
userHasPermissionToRedactOther = userHasPermissionToRedactOther,
userHasPermissionToSendReaction = userHasPermissionToSendReaction, userHasPermissionToSendReaction = userHasPermissionToSendReaction,
composerState = composerState, composerState = composerState,
voiceMessageComposerState = voiceMessageComposerState, voiceMessageComposerState = voiceMessageComposerState,
@ -312,7 +313,7 @@ class MessagesPresenter @AssistedInject constructor(
} }
} }
private suspend fun handleActionEdit( private fun handleActionEdit(
targetEvent: TimelineItem.Event, targetEvent: TimelineItem.Event,
composerState: MessageComposerState, composerState: MessageComposerState,
enableTextFormatting: Boolean, enableTextFormatting: Boolean,

View file

@ -36,7 +36,8 @@ data class MessagesState(
val roomName: AsyncData<String>, val roomName: AsyncData<String>,
val roomAvatar: AsyncData<AvatarData>, val roomAvatar: AsyncData<AvatarData>,
val userHasPermissionToSendMessage: Boolean, val userHasPermissionToSendMessage: Boolean,
val userHasPermissionToRedact: Boolean, val userHasPermissionToRedactOwn: Boolean,
val userHasPermissionToRedactOther: Boolean,
val userHasPermissionToSendReaction: Boolean, val userHasPermissionToSendReaction: Boolean,
val composerState: MessageComposerState, val composerState: MessageComposerState,
val voiceMessageComposerState: VoiceMessageComposerState, val voiceMessageComposerState: VoiceMessageComposerState,

View file

@ -86,7 +86,8 @@ fun aMessagesState() = MessagesState(
roomName = AsyncData.Success("Room name"), roomName = AsyncData.Success("Room name"),
roomAvatar = AsyncData.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)), roomAvatar = AsyncData.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)),
userHasPermissionToSendMessage = true, userHasPermissionToSendMessage = true,
userHasPermissionToRedact = false, userHasPermissionToRedactOwn = false,
userHasPermissionToRedactOther = false,
userHasPermissionToSendReaction = true, userHasPermissionToSendReaction = true,
composerState = aMessageComposerState().copy( composerState = aMessageComposerState().copy(
richTextEditorState = RichTextEditorState("Hello", initialFocus = true), richTextEditorState = RichTextEditorState("Hello", initialFocus = true),

View file

@ -41,8 +41,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@ -160,7 +162,8 @@ fun MessagesView(
state.actionListState.eventSink( state.actionListState.eventSink(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = event, event = event,
canRedact = state.userHasPermissionToRedact, canRedactOwn = state.userHasPermissionToRedactOwn,
canRedactOther = state.userHasPermissionToRedactOther,
canSendMessage = state.userHasPermissionToSendMessage, canSendMessage = state.userHasPermissionToSendMessage,
canSendReaction = state.userHasPermissionToSendReaction, canSendReaction = state.userHasPermissionToSendReaction,
) )
@ -293,8 +296,11 @@ private fun AttachmentStateView(
) { ) {
when (state) { when (state) {
AttachmentsState.None -> Unit AttachmentsState.None -> Unit
is AttachmentsState.Previewing -> LaunchedEffect(state) { is AttachmentsState.Previewing -> {
onPreviewAttachments(state.attachments) val latestOnPreviewAttachments by rememberUpdatedState(onPreviewAttachments)
LaunchedEffect(state) {
latestOnPreviewAttachments(state.attachments)
}
} }
is AttachmentsState.Sending -> { is AttachmentsState.Sending -> {
ProgressDialog( ProgressDialog(

View file

@ -22,7 +22,8 @@ sealed interface ActionListEvents {
data object Clear : ActionListEvents data object Clear : ActionListEvents
data class ComputeForMessage( data class ComputeForMessage(
val event: TimelineItem.Event, val event: TimelineItem.Event,
val canRedact: Boolean, val canRedactOwn: Boolean,
val canRedactOther: Boolean,
val canSendMessage: Boolean, val canSendMessage: Boolean,
val canSendReaction: Boolean, val canSendReaction: Boolean,
) : ActionListEvents ) : ActionListEvents

View file

@ -31,7 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied 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.features.messages.impl.timeline.model.event.canReact
import io.element.android.features.preferences.api.store.PreferencesStore import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -39,7 +39,7 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class ActionListPresenter @Inject constructor( class ActionListPresenter @Inject constructor(
private val preferencesStore: PreferencesStore, private val appPreferencesStore: AppPreferencesStore,
) : Presenter<ActionListState> { ) : Presenter<ActionListState> {
@Composable @Composable
override fun present(): ActionListState { override fun present(): ActionListState {
@ -49,14 +49,15 @@ class ActionListPresenter @Inject constructor(
mutableStateOf(ActionListState.Target.None) mutableStateOf(ActionListState.Target.None)
} }
val isDeveloperModeEnabled by preferencesStore.isDeveloperModeEnabledFlow().collectAsState(initial = false) val isDeveloperModeEnabled by appPreferencesStore.isDeveloperModeEnabledFlow().collectAsState(initial = false)
fun handleEvents(event: ActionListEvents) { fun handleEvents(event: ActionListEvents) {
when (event) { when (event) {
ActionListEvents.Clear -> target.value = ActionListState.Target.None ActionListEvents.Clear -> target.value = ActionListState.Target.None
is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage( is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(
timelineItem = event.event, timelineItem = event.event,
userCanRedact = event.canRedact, userCanRedactOwn = event.canRedactOwn,
userCanRedactOther = event.canRedactOther,
userCanSendMessage = event.canSendMessage, userCanSendMessage = event.canSendMessage,
userCanSendReaction = event.canSendReaction, userCanSendReaction = event.canSendReaction,
isDeveloperModeEnabled = isDeveloperModeEnabled, isDeveloperModeEnabled = isDeveloperModeEnabled,
@ -73,13 +74,15 @@ class ActionListPresenter @Inject constructor(
private fun CoroutineScope.computeForMessage( private fun CoroutineScope.computeForMessage(
timelineItem: TimelineItem.Event, timelineItem: TimelineItem.Event,
userCanRedact: Boolean, userCanRedactOwn: Boolean,
userCanRedactOther: Boolean,
userCanSendMessage: Boolean, userCanSendMessage: Boolean,
userCanSendReaction: Boolean, userCanSendReaction: Boolean,
isDeveloperModeEnabled: Boolean, isDeveloperModeEnabled: Boolean,
target: MutableState<ActionListState.Target> target: MutableState<ActionListState.Target>
) = launch { ) = launch {
target.value = ActionListState.Target.Loading(timelineItem) target.value = ActionListState.Target.Loading(timelineItem)
val canRedact = timelineItem.isMine && userCanRedactOwn || !timelineItem.isMine && userCanRedactOther
val actions = val actions =
when (timelineItem.content) { when (timelineItem.content) {
is TimelineItemRedactedContent -> { is TimelineItemRedactedContent -> {
@ -98,8 +101,10 @@ class ActionListPresenter @Inject constructor(
} }
} }
is TimelineItemPollContent -> { is TimelineItemPollContent -> {
val canEndPoll = timelineItem.isRemote &&
!timelineItem.content.isEnded &&
(timelineItem.isMine || canRedact)
buildList { buildList {
val isMineOrCanRedact = timelineItem.isMine || userCanRedact
if (timelineItem.isRemote) { if (timelineItem.isRemote) {
// Can only reply or forward messages already uploaded to the server // Can only reply or forward messages already uploaded to the server
add(TimelineItemAction.Reply) add(TimelineItemAction.Reply)
@ -107,7 +112,7 @@ class ActionListPresenter @Inject constructor(
if (timelineItem.isRemote && timelineItem.isEditable) { if (timelineItem.isRemote && timelineItem.isEditable) {
add(TimelineItemAction.Edit) add(TimelineItemAction.Edit)
} }
if (timelineItem.isRemote && !timelineItem.content.isEnded && isMineOrCanRedact) { if (canEndPoll) {
add(TimelineItemAction.EndPoll) add(TimelineItemAction.EndPoll)
} }
if (timelineItem.content.canBeCopied()) { if (timelineItem.content.canBeCopied()) {
@ -119,7 +124,7 @@ class ActionListPresenter @Inject constructor(
if (!timelineItem.isMine) { if (!timelineItem.isMine) {
add(TimelineItemAction.ReportContent) add(TimelineItemAction.ReportContent)
} }
if (isMineOrCanRedact) { if (canRedact) {
add(TimelineItemAction.Redact) add(TimelineItemAction.Redact)
} }
} }
@ -136,7 +141,7 @@ class ActionListPresenter @Inject constructor(
if (!timelineItem.isMine) { if (!timelineItem.isMine) {
add(TimelineItemAction.ReportContent) add(TimelineItemAction.ReportContent)
} }
if (timelineItem.isMine || userCanRedact) { if (canRedact) {
add(TimelineItemAction.Redact) add(TimelineItemAction.Redact)
} }
} }
@ -169,7 +174,7 @@ class ActionListPresenter @Inject constructor(
if (!timelineItem.isMine) { if (!timelineItem.isMine) {
add(TimelineItemAction.ReportContent) add(TimelineItemAction.ReportContent)
} }
if (timelineItem.isMine || userCanRedact) { if (canRedact) {
add(TimelineItemAction.Redact) add(TimelineItemAction.Redact)
} }
} }

View file

@ -24,6 +24,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -57,8 +59,9 @@ fun AttachmentsPreviewView(
} }
if (state.sendActionState is SendActionState.Done) { if (state.sendActionState is SendActionState.Done) {
val latestOnDismiss by rememberUpdatedState(onDismiss)
LaunchedEffect(state.sendActionState) { LaunchedEffect(state.sendActionState) {
onDismiss() latestOnDismiss()
} }
} }

View file

@ -18,9 +18,6 @@ package io.element.android.features.messages.impl.forward
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.message.RoomMessage
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -51,30 +48,3 @@ fun aForwardMessagesState(
forwardingSucceeded = forwardingSucceeded, forwardingSucceeded = forwardingSucceeded,
eventSink = {} eventSink = {}
) )
internal fun aForwardMessagesRoomList() = persistentListOf(
aRoomDetailsState(),
aRoomDetailsState(roomId = RoomId("!room2:domain"), canonicalAlias = "#element-x-room:matrix.org"),
)
fun aRoomDetailsState(
roomId: RoomId = RoomId("!room:domain"),
name: String = "roomName",
canonicalAlias: String? = null,
isDirect: Boolean = true,
avatarURLString: String? = null,
lastMessage: RoomMessage? = null,
lastMessageTimestamp: Long? = null,
unreadNotificationCount: Int = 0,
inviter: RoomMember? = null,
) = RoomSummaryDetails(
roomId = roomId,
name = name,
canonicalAlias = canonicalAlias,
isDirect = isDirect,
avatarURLString = avatarURLString,
lastMessage = lastMessage,
lastMessageTimestamp = lastMessageTimestamp,
unreadNotificationCount = unreadNotificationCount,
inviter = inviter,
)

View file

@ -43,6 +43,7 @@ sealed interface MessageComposerEvents {
data class ToggleTextFormatting(val enabled: Boolean) : MessageComposerEvents data class ToggleTextFormatting(val enabled: Boolean) : MessageComposerEvents
data object CancelSendAttachment : MessageComposerEvents data object CancelSendAttachment : MessageComposerEvents
data class Error(val error: Throwable) : MessageComposerEvents data class Error(val error: Throwable) : MessageComposerEvents
data class TypingNotice(val isTyping: Boolean) : MessageComposerEvents
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents
data class InsertMention(val mention: MentionSuggestion) : MessageComposerEvents data class InsertMention(val mention: MentionSuggestion) : MessageComposerEvents
} }

View file

@ -20,6 +20,7 @@ import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -207,6 +208,15 @@ class MessageComposerPresenter @Inject constructor(
.collect() .collect()
} }
DisposableEffect(Unit) {
// Declare that the user is not typing anymore when the composer is disposed
onDispose {
appCoroutineScope.launch {
room.typingNotice(false)
}
}
}
fun handleEvents(event: MessageComposerEvents) { fun handleEvents(event: MessageComposerEvents) {
when (event) { when (event) {
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
@ -299,6 +309,11 @@ class MessageComposerPresenter @Inject constructor(
is MessageComposerEvents.Error -> { is MessageComposerEvents.Error -> {
analyticsService.trackError(event.error) analyticsService.trackError(event.error)
} }
is MessageComposerEvents.TypingNotice -> {
localCoroutineScope.launch {
room.typingNotice(event.isTyping)
}
}
is MessageComposerEvents.SuggestionReceived -> { is MessageComposerEvents.SuggestionReceived -> {
suggestionSearchTrigger.value = event.suggestion suggestionSearchTrigger.value = event.suggestion
} }

View file

@ -78,6 +78,10 @@ internal fun MessageComposerView(
state.eventSink(MessageComposerEvents.Error(error)) state.eventSink(MessageComposerEvents.Error(error))
} }
fun onTyping(typing: Boolean) {
state.eventSink(MessageComposerEvents.TypingNotice(typing))
}
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
fun onRequestFocus() { fun onRequestFocus() {
coroutineScope.launch { coroutineScope.launch {
@ -121,6 +125,7 @@ internal fun MessageComposerView(
onDeleteVoiceMessage = onDeleteVoiceMessage, onDeleteVoiceMessage = onDeleteVoiceMessage,
onSuggestionReceived = ::onSuggestionReceived, onSuggestionReceived = ::onSuggestionReceived,
onError = ::onError, onError = ::onError,
onTyping = ::onTyping,
currentUserId = state.currentUserId, currentUserId = state.currentUserId,
onRichContentSelected = ::sendUri, onRichContentSelected = ::sendUri,
) )

View file

@ -38,6 +38,7 @@ import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.features.poll.api.actions.EndPollAction import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -53,6 +54,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -72,6 +74,7 @@ class TimelinePresenter @AssistedInject constructor(
private val redactedVoiceMessageManager: RedactedVoiceMessageManager, private val redactedVoiceMessageManager: RedactedVoiceMessageManager,
private val sendPollResponseAction: SendPollResponseAction, private val sendPollResponseAction: SendPollResponseAction,
private val endPollAction: EndPollAction, private val endPollAction: EndPollAction,
private val sessionPreferencesStore: SessionPreferencesStore,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -102,6 +105,8 @@ class TimelinePresenter @AssistedInject constructor(
val sessionVerifiedStatus by verificationService.sessionVerifiedStatus.collectAsState() val sessionVerifiedStatus by verificationService.sessionVerifiedStatus.collectAsState()
val keyBackupState by encryptionService.backupStateStateFlow.collectAsState() val keyBackupState by encryptionService.backupStateStateFlow.collectAsState()
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
val sessionState by remember { val sessionState by remember {
derivedStateOf { derivedStateOf {
SessionState( SessionState(
@ -111,8 +116,6 @@ class TimelinePresenter @AssistedInject constructor(
} }
} }
val membersState by room.membersStateFlow.collectAsState()
fun handleEvents(event: TimelineEvents) { fun handleEvents(event: TimelineEvents) {
when (event) { when (event) {
TimelineEvents.LoadMore -> localScope.paginateBackwards() TimelineEvents.LoadMore -> localScope.paginateBackwards()
@ -125,7 +128,8 @@ class TimelinePresenter @AssistedInject constructor(
firstVisibleIndex = event.firstIndex, firstVisibleIndex = event.firstIndex,
timelineItems = timelineItems, timelineItems = timelineItems,
lastReadReceiptIndex = lastReadReceiptIndex, lastReadReceiptIndex = lastReadReceiptIndex,
lastReadReceiptId = lastReadReceiptId lastReadReceiptId = lastReadReceiptId,
readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE,
) )
} }
is TimelineEvents.PollAnswerSelected -> appScope.launch { is TimelineEvents.PollAnswerSelected -> appScope.launch {
@ -149,13 +153,12 @@ class TimelinePresenter @AssistedInject constructor(
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
timeline combine(timeline.timelineItems, room.membersStateFlow) { items, membersState ->
.timelineItems
.onEach {
timelineItemsFactory.replaceWith( timelineItemsFactory.replaceWith(
timelineItems = it, timelineItems = items,
roomMembers = membersState.roomMembers().orEmpty() roomMembers = membersState.roomMembers().orEmpty()
) )
items
} }
.onEach { timelineItems -> .onEach { timelineItems ->
if (timelineItems.isEmpty()) { if (timelineItems.isEmpty()) {
@ -225,13 +228,14 @@ class TimelinePresenter @AssistedInject constructor(
timelineItems: ImmutableList<TimelineItem>, timelineItems: ImmutableList<TimelineItem>,
lastReadReceiptIndex: MutableState<Int>, lastReadReceiptIndex: MutableState<Int>,
lastReadReceiptId: MutableState<EventId?>, lastReadReceiptId: MutableState<EventId?>,
readReceiptType: ReceiptType,
) = launch(dispatchers.computation) { ) = launch(dispatchers.computation) {
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item // Get last valid EventId seen by the user, as the first index might refer to a Virtual item
val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems) val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)
if (eventId != null && firstVisibleIndex <= lastReadReceiptIndex.value && eventId != lastReadReceiptId.value) { if (eventId != null && firstVisibleIndex <= lastReadReceiptIndex.value && eventId != lastReadReceiptId.value) {
lastReadReceiptIndex.value = firstVisibleIndex lastReadReceiptIndex.value = firstVisibleIndex
lastReadReceiptId.value = eventId lastReadReceiptId.value = eventId
timeline.sendReadReceipt(eventId = eventId, receiptType = ReceiptType.READ) timeline.sendReadReceipt(eventId = eventId, receiptType = readReceiptType)
} }
} }

View file

@ -42,6 +42,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
@ -145,7 +146,7 @@ fun TimelineView(
} }
} }
} }
if (state.paginationState.beginningOfRoomReached) { if (state.paginationState.beginningOfRoomReached && !state.timelineRoomInfo.isDirect) {
item(contentType = "BeginningOfRoomReached") { item(contentType = "BeginningOfRoomReached") {
TimelineItemRoomBeginningView(roomName = roomName) TimelineItemRoomBeginningView(roomName = roomName)
} }
@ -193,10 +194,11 @@ private fun BoxScope.TimelineScrollHelper(
} }
} }
val latestOnScrollFinishedAt by rememberUpdatedState(onScrollFinishedAt)
LaunchedEffect(isScrollFinished, isTimelineEmpty) { LaunchedEffect(isScrollFinished, isTimelineEmpty) {
if (isScrollFinished && !isTimelineEmpty) { if (isScrollFinished && !isTimelineEmpty) {
// Notify the parent composable about the first visible item index when scrolling finishes // Notify the parent composable about the first visible item index when scrolling finishes
onScrollFinishedAt(lazyListState.firstVisibleItemIndex) latestOnScrollFinishedAt(lazyListState.firstVisibleItemIndex)
} }
} }

View file

@ -18,10 +18,11 @@ package io.element.android.features.messages.impl.timeline.components.layout
import android.text.Layout import android.text.Layout
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Constraints
@ -59,23 +60,27 @@ fun ContentAvoidingLayout(
) { ) {
val scope = remember { ContentAvoidingLayoutScopeInstance() } val scope = remember { ContentAvoidingLayoutScopeInstance() }
SubcomposeLayout( Layout(
modifier = modifier, modifier = modifier,
) { constraints -> content = {
scope.content()
overlay()
}
) { measurables, constraints ->
// Measure the `overlay` view first, in case we need to shrink the `content` // Measure the `overlay` view first, in case we need to shrink the `content`
val overlayPlaceable = subcompose(0, overlay).first().measure(Constraints(minWidth = 0, maxWidth = constraints.maxWidth)) val overlayPlaceable = measurables.last().measure(Constraints(minWidth = 0, maxWidth = constraints.maxWidth))
val contentConstraints = if (shrinkContent) { val contentConstraints = if (shrinkContent) {
Constraints(minWidth = 0, maxWidth = constraints.maxWidth - overlayPlaceable.width) Constraints(minWidth = 0, maxWidth = constraints.maxWidth - overlayPlaceable.width)
} else { } else {
Constraints(minWidth = 0, maxWidth = constraints.maxWidth) Constraints(minWidth = 0, maxWidth = constraints.maxWidth)
} }
val contentPlaceable = subcompose(1) { scope.content() }.first().measure(contentConstraints) val contentPlaceable = measurables.first().measure(contentConstraints)
var layoutWidth = contentPlaceable.width var layoutWidth = contentPlaceable.width
var layoutHeight = contentPlaceable.height var layoutHeight = contentPlaceable.height
val data = scope.data val data = scope.data.value
// Free space = width of the whole component - width of its non overlapping contents // Free space = width of the whole component - width of its non overlapping contents
val freeSpace = max(contentPlaceable.width - data.nonOverlappingContentWidth, 0) val freeSpace = max(contentPlaceable.width - data.nonOverlappingContentWidth, 0)
@ -135,13 +140,10 @@ interface ContentAvoidingLayoutScope {
} }
private class ContentAvoidingLayoutScopeInstance( private class ContentAvoidingLayoutScopeInstance(
val data: ContentAvoidingLayoutData = ContentAvoidingLayoutData(), val data: MutableState<ContentAvoidingLayoutData> = mutableStateOf(ContentAvoidingLayoutData()),
) : ContentAvoidingLayoutScope { ) : ContentAvoidingLayoutScope {
override fun onContentLayoutChanged(data: ContentAvoidingLayoutData) { override fun onContentLayoutChanged(data: ContentAvoidingLayoutData) {
this.data.contentWidth = data.contentWidth this.data.value = data
this.data.contentHeight = data.contentHeight
this.data.nonOverlappingContentWidth = data.nonOverlappingContentWidth
this.data.nonOverlappingContentHeight = data.nonOverlappingContentHeight
} }
} }

View file

@ -87,7 +87,16 @@ class TimelineItemsFactory @Inject constructor(
newTimelineItemStates.add(timelineItemState) newTimelineItemStates.add(timelineItemState)
} }
} else { } else {
newTimelineItemStates.add(cacheItem) val updatedItem = if (cacheItem is TimelineItem.Event && roomMembers.isNotEmpty()) {
eventItemFactory.update(
timelineItem = cacheItem,
receivedMatrixTimelineItem = timelineItems[index] as MatrixTimelineItem.Event,
roomMembers = roomMembers
)
} else {
cacheItem
}
newTimelineItemStates.add(updatedItem)
} }
} }
val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList() val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList()

View file

@ -24,13 +24,13 @@ import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParse
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent 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.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent 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 import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent 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.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import javax.inject.Inject import javax.inject.Inject
class TimelineItemContentFactory @Inject constructor( class TimelineItemContentFactory @Inject constructor(
@ -50,7 +50,7 @@ class TimelineItemContentFactory @Inject constructor(
is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent) is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent)
is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent) is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent)
is MessageContent -> { is MessageContent -> {
val senderDisplayName = (eventTimelineItem.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: eventTimelineItem.sender.value val senderDisplayName = eventTimelineItem.senderProfile.getDisambiguatedDisplayName(eventTimelineItem.sender)
messageFactory.create(itemContent, senderDisplayName, eventTimelineItem.eventId) messageFactory.create(itemContent, senderDisplayName, eventTimelineItem.eventId)
} }
is ProfileChangeContent -> profileChangeFactory.create(eventTimelineItem) is ProfileChangeContent -> profileChangeFactory.create(eventTimelineItem)

View file

@ -33,6 +33,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
@ -52,21 +53,7 @@ class TimelineItemEventFactory @Inject constructor(
val currentSender = currentTimelineItem.event.sender val currentSender = currentTimelineItem.event.sender
val groupPosition = val groupPosition =
computeGroupPosition(currentTimelineItem, timelineItems, index) computeGroupPosition(currentTimelineItem, timelineItems, index)
val senderDisplayName: String? val (senderDisplayName, senderAvatarUrl) = currentTimelineItem.getSenderInfo()
val senderAvatarUrl: String?
when (val senderProfile = currentTimelineItem.event.senderProfile) {
ProfileTimelineDetails.Unavailable,
ProfileTimelineDetails.Pending,
is ProfileTimelineDetails.Error -> {
senderDisplayName = null
senderAvatarUrl = null
}
is ProfileTimelineDetails.Ready -> {
senderDisplayName = senderProfile.displayName
senderAvatarUrl = senderProfile.avatarUrl
}
}
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT) val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
val sentTime = timeFormatter.format(Date(currentTimelineItem.event.timestamp)) val sentTime = timeFormatter.format(Date(currentTimelineItem.event.timestamp))
@ -100,6 +87,36 @@ class TimelineItemEventFactory @Inject constructor(
) )
} }
fun update(
timelineItem: TimelineItem.Event,
receivedMatrixTimelineItem: MatrixTimelineItem.Event,
roomMembers: List<RoomMember>,
): TimelineItem.Event {
return timelineItem.copy(
readReceiptState = receivedMatrixTimelineItem.computeReadReceiptState(roomMembers)
)
}
private fun MatrixTimelineItem.Event.getSenderInfo(): Pair<String?, String?> {
val senderDisplayName: String?
val senderAvatarUrl: String?
when (val senderProfile = event.senderProfile) {
ProfileTimelineDetails.Unavailable,
ProfileTimelineDetails.Pending,
is ProfileTimelineDetails.Error -> {
senderDisplayName = null
senderAvatarUrl = null
}
is ProfileTimelineDetails.Ready -> {
senderDisplayName = senderProfile.getDisambiguatedDisplayName(event.sender)
senderAvatarUrl = senderProfile.avatarUrl
}
}
return senderDisplayName to senderAvatarUrl
}
private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions { private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions {
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT) val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
var aggregatedReactions = event.reactions.map { reaction -> var aggregatedReactions = event.reactions.map { reaction ->

View file

@ -23,7 +23,7 @@
<string name="screen_room_attachment_source_camera_photo">"Prendre une photo"</string> <string name="screen_room_attachment_source_camera_photo">"Prendre une photo"</string>
<string name="screen_room_attachment_source_camera_video">"Enregistrer une vidéo"</string> <string name="screen_room_attachment_source_camera_video">"Enregistrer une vidéo"</string>
<string name="screen_room_attachment_source_files">"Pièce jointe"</string> <string name="screen_room_attachment_source_files">"Pièce jointe"</string>
<string name="screen_room_attachment_source_gallery">"Gallerie Photo et Vidéo"</string> <string name="screen_room_attachment_source_gallery">"Galerie Photo et Vidéo"</string>
<string name="screen_room_attachment_source_location">"Position"</string> <string name="screen_room_attachment_source_location">"Position"</string>
<string name="screen_room_attachment_source_poll">"Sondage"</string> <string name="screen_room_attachment_source_poll">"Sondage"</string>
<string name="screen_room_attachment_text_formatting">"Formatage du texte"</string> <string name="screen_room_attachment_text_formatting">"Formatage du texte"</string>

View file

@ -59,7 +59,8 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore import io.element.android.libraries.featureflag.test.InMemoryAppPreferencesStore
import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
@ -120,7 +121,7 @@ class MessagesPresenterTest {
assertThat(initialState.roomAvatar) assertThat(initialState.roomAvatar)
.isEqualTo(AsyncData.Success(AvatarData(id = A_ROOM_ID.value, name = "", url = AN_AVATAR_URL, size = AvatarSize.TimelineRoom))) .isEqualTo(AsyncData.Success(AvatarData(id = A_ROOM_ID.value, name = "", url = AN_AVATAR_URL, size = AvatarSize.TimelineRoom)))
assertThat(initialState.userHasPermissionToSendMessage).isTrue() assertThat(initialState.userHasPermissionToSendMessage).isTrue()
assertThat(initialState.userHasPermissionToRedact).isFalse() assertThat(initialState.userHasPermissionToRedactOwn).isFalse()
assertThat(initialState.hasNetworkConnection).isTrue() assertThat(initialState.hasNetworkConnection).isTrue()
assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.inviteProgress).isEqualTo(AsyncData.Uninitialized) assertThat(initialState.inviteProgress).isEqualTo(AsyncData.Uninitialized)
@ -601,14 +602,29 @@ class MessagesPresenterTest {
} }
@Test @Test
fun `present - permission to redact`() = runTest { fun `present - permission to redact own`() = runTest {
val matrixRoom = FakeMatrixRoom(canRedact = true) val matrixRoom = FakeMatrixRoom(canRedactOwn = true)
val presenter = createMessagesPresenter(matrixRoom = matrixRoom) val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = consumeItemsUntilPredicate { it.userHasPermissionToRedact }.last() val initialState = consumeItemsUntilPredicate { it.userHasPermissionToRedactOwn }.last()
assertThat(initialState.userHasPermissionToRedact).isTrue() assertThat(initialState.userHasPermissionToRedactOwn).isTrue()
assertThat(initialState.userHasPermissionToRedactOther).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - permission to redact other`() = runTest {
val matrixRoom = FakeMatrixRoom(canRedactOther = true)
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = consumeItemsUntilPredicate { it.userHasPermissionToRedactOther }.last()
assertThat(initialState.userHasPermissionToRedactOwn).isFalse()
assertThat(initialState.userHasPermissionToRedactOther).isTrue()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -649,10 +665,11 @@ class MessagesPresenterTest {
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
currentSessionIdHolder: CurrentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
): MessagesPresenter { ): MessagesPresenter {
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom) val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter) val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
val appPreferencesStore = InMemoryAppPreferencesStore(isRichTextEditorEnabled = true)
val sessionPreferencesStore = InMemorySessionPreferencesStore()
val messageComposerPresenter = MessageComposerPresenter( val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this, appCoroutineScope = this,
room = matrixRoom, room = matrixRoom,
@ -687,14 +704,14 @@ class MessagesPresenterTest {
redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(), redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
endPollAction = FakeEndPollAction(), endPollAction = FakeEndPollAction(),
sendPollResponseAction = FakeSendPollResponseAction(), sendPollResponseAction = FakeSendPollResponseAction(),
sessionPreferencesStore = sessionPreferencesStore,
) )
val timelinePresenterFactory = object : TimelinePresenter.Factory { val timelinePresenterFactory = object : TimelinePresenter.Factory {
override fun create(navigator: MessagesNavigator): TimelinePresenter { override fun create(navigator: MessagesNavigator): TimelinePresenter {
return timelinePresenter return timelinePresenter
} }
} }
val preferencesStore = InMemoryPreferencesStore(isRichTextEditorEnabled = true) val actionListPresenter = ActionListPresenter(appPreferencesStore = appPreferencesStore)
val actionListPresenter = ActionListPresenter(preferencesStore = preferencesStore)
val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter() val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter()
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider()) val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom) val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
@ -714,11 +731,10 @@ class MessagesPresenterTest {
messageSummaryFormatter = FakeMessageSummaryFormatter(), messageSummaryFormatter = FakeMessageSummaryFormatter(),
navigator = navigator, navigator = navigator,
clipboardHelper = clipboardHelper, clipboardHelper = clipboardHelper,
preferencesStore = preferencesStore, appPreferencesStore = appPreferencesStore,
featureFlagsService = FakeFeatureFlagService(), featureFlagsService = FakeFeatureFlagService(),
buildMeta = aBuildMeta(), buildMeta = aBuildMeta(),
dispatchers = coroutineDispatchers, dispatchers = coroutineDispatchers,
currentSessionIdHolder = currentSessionIdHolder,
htmlConverterProvider = FakeHtmlConverterProvider(), htmlConverterProvider = FakeHtmlConverterProvider(),
) )
} }

View file

@ -30,7 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore import io.element.android.libraries.featureflag.test.InMemoryAppPreferencesStore
import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -38,6 +38,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@Suppress("LargeClass")
class ActionListPresenterTest { class ActionListPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@ -61,7 +62,15 @@ class ActionListPresenterTest {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent(isMine = true, content = TimelineItemRedactedContent) val messageEvent = aMessageEvent(isMine = true, content = TimelineItemRedactedContent)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
canRedactOwn = false,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
)
)
// val loadingState = awaitItem() // val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem() val successState = awaitItem()
@ -87,7 +96,15 @@ class ActionListPresenterTest {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent(isMine = false, content = TimelineItemRedactedContent) val messageEvent = aMessageEvent(isMine = false, content = TimelineItemRedactedContent)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
canRedactOwn = false,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
)
)
// val loadingState = awaitItem() // val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem() val successState = awaitItem()
@ -116,7 +133,15 @@ class ActionListPresenterTest {
isMine = false, isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
canRedactOwn = false,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
)
)
// val loadingState = awaitItem() // val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem() val successState = awaitItem()
@ -149,7 +174,15 @@ class ActionListPresenterTest {
isMine = false, isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = false, canSendReaction = true)) initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
canRedactOwn = true,
canRedactOther = false,
canSendMessage = false,
canSendReaction = true
)
)
// val loadingState = awaitItem() // val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem() val successState = awaitItem()
@ -181,7 +214,15 @@ class ActionListPresenterTest {
isMine = false, isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = true, canSendMessage = true, canSendReaction = true)) initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
canRedactOwn = false,
canRedactOther = true,
canSendMessage = true,
canSendReaction = true,
)
)
val successState = awaitItem() val successState = awaitItem()
assertThat(successState.target).isEqualTo( assertThat(successState.target).isEqualTo(
ActionListState.Target.Success( ActionListState.Target.Success(
@ -213,7 +254,15 @@ class ActionListPresenterTest {
isMine = false, isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = true, canSendMessage = true, canSendReaction = false)) initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
canRedactOwn = false,
canRedactOther = true,
canSendMessage = true,
canSendReaction = false
)
)
val successState = awaitItem() val successState = awaitItem()
assertThat(successState.target).isEqualTo( assertThat(successState.target).isEqualTo(
ActionListState.Target.Success( ActionListState.Target.Success(
@ -245,7 +294,15 @@ class ActionListPresenterTest {
isMine = true, isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
)
)
// val loadingState = awaitItem() // val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem() val successState = awaitItem()
@ -268,6 +325,47 @@ class ActionListPresenterTest {
} }
} }
@Test
fun `present - compute for my message cannot redact`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
canRedactOwn = false,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
)
)
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Copy,
TimelineItemAction.ViewSource,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test @Test
fun `present - compute for a media item`() = runTest { fun `present - compute for a media item`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
@ -279,7 +377,15 @@ class ActionListPresenterTest {
isMine = true, isMine = true,
content = aTimelineItemImageContent(), content = aTimelineItemImageContent(),
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
)
)
// val loadingState = awaitItem() // val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem() val successState = awaitItem()
@ -311,7 +417,15 @@ class ActionListPresenterTest {
isMine = true, isMine = true,
content = aTimelineItemStateEventContent(), content = aTimelineItemStateEventContent(),
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = stateEvent,
canRedactOwn = false,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
)
)
// val loadingState = awaitItem() // val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem() val successState = awaitItem()
@ -341,7 +455,15 @@ class ActionListPresenterTest {
isMine = true, isMine = true,
content = aTimelineItemStateEventContent(), content = aTimelineItemStateEventContent(),
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = stateEvent,
canRedactOwn = false,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
)
)
// val loadingState = awaitItem() // val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem() val successState = awaitItem()
@ -370,7 +492,15 @@ class ActionListPresenterTest {
isMine = true, isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
)
)
// val loadingState = awaitItem() // val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem() val successState = awaitItem()
@ -408,10 +538,26 @@ class ActionListPresenterTest {
content = TimelineItemRedactedContent, content = TimelineItemRedactedContent,
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
canRedactOwn = false,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
)
)
assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java) assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(redactedEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = redactedEvent,
canRedactOwn = false,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
)
)
awaitItem().run { awaitItem().run {
assertThat(target).isEqualTo(ActionListState.Target.None) assertThat(target).isEqualTo(ActionListState.Target.None)
} }
@ -432,7 +578,15 @@ class ActionListPresenterTest {
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null), content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null),
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
)
)
val successState = awaitItem() val successState = awaitItem()
assertThat(successState.target).isEqualTo( assertThat(successState.target).isEqualTo(
ActionListState.Target.Success( ActionListState.Target.Success(
@ -460,7 +614,15 @@ class ActionListPresenterTest {
isEditable = true, isEditable = true,
content = aTimelineItemPollContent(answerItems = aPollAnswerItemList(hasVotes = false)), content = aTimelineItemPollContent(answerItems = aPollAnswerItemList(hasVotes = false)),
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
)
)
val successState = awaitItem() val successState = awaitItem()
assertThat(successState.target).isEqualTo( assertThat(successState.target).isEqualTo(
ActionListState.Target.Success( ActionListState.Target.Success(
@ -489,7 +651,15 @@ class ActionListPresenterTest {
isEditable = false, isEditable = false,
content = aTimelineItemPollContent(answerItems = aPollAnswerItemList(hasVotes = true)), content = aTimelineItemPollContent(answerItems = aPollAnswerItemList(hasVotes = true)),
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
)
)
val successState = awaitItem() val successState = awaitItem()
assertThat(successState.target).isEqualTo( assertThat(successState.target).isEqualTo(
ActionListState.Target.Success( ActionListState.Target.Success(
@ -517,7 +687,15 @@ class ActionListPresenterTest {
isEditable = false, isEditable = false,
content = aTimelineItemPollContent(isEnded = true), content = aTimelineItemPollContent(isEnded = true),
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
)
)
val successState = awaitItem() val successState = awaitItem()
assertThat(successState.target).isEqualTo( assertThat(successState.target).isEqualTo(
ActionListState.Target.Success( ActionListState.Target.Success(
@ -543,7 +721,15 @@ class ActionListPresenterTest {
isMine = true, isMine = true,
content = aTimelineItemVoiceContent(), content = aTimelineItemVoiceContent(),
) )
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
)
)
val successState = awaitItem() val successState = awaitItem()
assertThat(successState.target).isEqualTo( assertThat(successState.target).isEqualTo(
ActionListState.Target.Success( ActionListState.Target.Success(
@ -561,6 +747,6 @@ class ActionListPresenterTest {
} }
private fun createActionListPresenter(isDeveloperModeEnabled: Boolean): ActionListPresenter { private fun createActionListPresenter(isDeveloperModeEnabled: Boolean): ActionListPresenter {
val preferencesStore = InMemoryPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled) val preferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled)
return ActionListPresenter(preferencesStore = preferencesStore) return ActionListPresenter(appPreferencesStore = preferencesStore)
} }

View file

@ -23,7 +23,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -54,7 +54,7 @@ class ForwardMessagesPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)
val summary = aRoomSummaryDetail() val summary = aRoomSummaryDetails()
presenter.onRoomSelected(listOf(summary.roomId)) presenter.onRoomSelected(listOf(summary.roomId))
val forwardingState = awaitItem() val forwardingState = awaitItem()
assertThat(forwardingState.isForwarding).isTrue() assertThat(forwardingState.isForwarding).isTrue()
@ -74,7 +74,7 @@ class ForwardMessagesPresenterTests {
// Test failed forwarding // Test failed forwarding
room.givenForwardEventResult(Result.failure(Throwable("error"))) room.givenForwardEventResult(Result.failure(Throwable("error")))
skipItems(1) skipItems(1)
val summary = aRoomSummaryDetail() val summary = aRoomSummaryDetails()
presenter.onRoomSelected(listOf(summary.roomId)) presenter.onRoomSelected(listOf(summary.roomId))
skipItems(1) skipItems(1)
val failedForwardState = awaitItem() val failedForwardState = awaitItem()

View file

@ -873,6 +873,21 @@ class MessageComposerPresenterTest {
} }
} }
@Test
fun `present - handle typing notice event`() = runTest {
val room = FakeMatrixRoom()
val presenter = createPresenter(room = room, coroutineScope = this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
assertThat(room.typingRecord).isEmpty()
initialState.eventSink.invoke(MessageComposerEvents.TypingNotice(true))
initialState.eventSink.invoke(MessageComposerEvents.TypingNotice(false))
assertThat(room.typingRecord).isEqualTo(listOf(true, false))
}
}
private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0): MessageComposerState { private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0): MessageComposerState {
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode) state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
skipItems(skipCount) skipItems(skipCount)

View file

@ -35,14 +35,20 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.poll.test.actions.FakeEndPollAction import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline 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.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction 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.event.ReactionSender
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem 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.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
@ -60,6 +66,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.util.Date import java.util.Date
import kotlin.time.Duration.Companion.seconds
private const val FAKE_UNIQUE_ID = "FAKE_UNIQUE_ID" private const val FAKE_UNIQUE_ID = "FAKE_UNIQUE_ID"
@ -129,13 +136,41 @@ class TimelinePresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
assertThat(timeline.sendReadReceiptCount).isEqualTo(0) assertThat(timeline.sentReadReceipts).isEmpty()
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
awaitWithLatch { latch -> awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
} }
assertThat(timeline.sendReadReceiptCount).isEqualTo(1) assertThat(timeline.sentReadReceipts).isNotEmpty()
assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - on scroll finished send a private read receipt if an event is before the index and public read receipts are disabled`() = runTest {
val timeline = FakeMatrixTimeline(
initialTimelineItems = listOf(
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem())
)
)
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
val presenter = createTimelinePresenter(
timeline = timeline,
sessionPreferencesStore = sessionPreferencesStore,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
assertThat(timeline.sentReadReceipts).isEmpty()
val initialState = awaitFirstItem()
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
}
assertThat(timeline.sentReadReceipts).isNotEmpty()
assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ_PRIVATE)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -151,13 +186,13 @@ class TimelinePresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
assertThat(timeline.sendReadReceiptCount).isEqualTo(0) assertThat(timeline.sentReadReceipts).isEmpty()
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
awaitWithLatch { latch -> awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
} }
assertThat(timeline.sendReadReceiptCount).isEqualTo(0) assertThat(timeline.sentReadReceipts).isEmpty()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -173,13 +208,13 @@ class TimelinePresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
assertThat(timeline.sendReadReceiptCount).isEqualTo(0) assertThat(timeline.sentReadReceipts).isEmpty()
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
awaitWithLatch { latch -> awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
} }
assertThat(timeline.sendReadReceiptCount).isEqualTo(0) assertThat(timeline.sentReadReceipts).isEmpty()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -353,6 +388,50 @@ class TimelinePresenterTest {
} }
} }
@Test
fun `present - when room member info is loaded, read receipts info should be updated`() = runTest {
val timeline = FakeMatrixTimeline(
listOf(
MatrixTimelineItem.Event(
FAKE_UNIQUE_ID,
anEventTimelineItem(
sender = A_USER_ID,
receipts = persistentListOf(
Receipt(
userId = A_USER_ID,
timestamp = 0L,
)
)
)
)
)
)
val room = FakeMatrixRoom(matrixTimeline = timeline).apply {
givenRoomMembersState(MatrixRoomMembersState.Unknown)
}
val avatarUrl = "https://domain.com/avatar.jpg"
val presenter = createTimelinePresenter(timeline, room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = consumeItemsUntilPredicate(30.seconds) { it.timelineItems.isNotEmpty() }.last()
val event = initialState.timelineItems.first() as TimelineItem.Event
assertThat(event.senderAvatar.url).isNull()
assertThat(event.readReceiptState.receipts.first().avatarData.url).isNull()
room.givenRoomMembersState(
MatrixRoomMembersState.Ready(
persistentListOf(aRoomMember(userId = A_USER_ID, avatarUrl = avatarUrl))
)
)
val updatedEvent = awaitItem().timelineItems.first() as TimelineItem.Event
assertThat(updatedEvent.readReceiptState.receipts.first().avatarData.url).isEqualTo(avatarUrl)
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T { private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
// Skip 1 item if Mentions feature is enabled // Skip 1 item if Mentions feature is enabled
if (FeatureFlags.Mentions.defaultValue) { if (FeatureFlags.Mentions.defaultValue) {
@ -363,15 +442,17 @@ class TimelinePresenterTest {
private fun TestScope.createTimelinePresenter( private fun TestScope.createTimelinePresenter(
timeline: MatrixTimeline = FakeMatrixTimeline(), timeline: MatrixTimeline = FakeMatrixTimeline(),
room: FakeMatrixRoom = FakeMatrixRoom(matrixTimeline = timeline),
timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory(),
redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(), redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(), messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(),
endPollAction: EndPollAction = FakeEndPollAction(), endPollAction: EndPollAction = FakeEndPollAction(),
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(), sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
): TimelinePresenter { ): TimelinePresenter {
return TimelinePresenter( return TimelinePresenter(
timelineItemsFactory = timelineItemsFactory, timelineItemsFactory = timelineItemsFactory,
room = FakeMatrixRoom(matrixTimeline = timeline), room = room,
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
appScope = this, appScope = this,
navigator = messagesNavigator, navigator = messagesNavigator,
@ -380,6 +461,7 @@ class TimelinePresenterTest {
redactedVoiceMessageManager = redactedVoiceMessageManager, redactedVoiceMessageManager = redactedVoiceMessageManager,
endPollAction = endPollAction, endPollAction = endPollAction,
sendPollResponseAction = sendPollResponseAction, sendPollResponseAction = sendPollResponseAction,
sessionPreferencesStore = sessionPreferencesStore,
) )
} }
} }

View file

@ -33,5 +33,6 @@ interface OnBoardingEntryPoint : FeatureEntryPoint {
fun onSignUp() fun onSignUp()
fun onSignIn() fun onSignIn()
fun onOpenDeveloperSettings() fun onOpenDeveloperSettings()
fun onReportProblem()
} }
} }

View file

@ -49,6 +49,10 @@ class OnBoardingNode @AssistedInject constructor(
plugins<OnBoardingEntryPoint.Callback>().forEach { it.onOpenDeveloperSettings() } plugins<OnBoardingEntryPoint.Callback>().forEach { it.onOpenDeveloperSettings() }
} }
private fun onReportProblem() {
plugins<OnBoardingEntryPoint.Callback>().forEach { it.onReportProblem() }
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
@ -59,6 +63,7 @@ class OnBoardingNode @AssistedInject constructor(
onCreateAccount = ::onSignUp, onCreateAccount = ::onSignUp,
onSignInWithQrCode = { /* Not supported yet */ }, onSignInWithQrCode = { /* Not supported yet */ },
onOpenDeveloperSettings = ::onOpenDeveloperSettings, onOpenDeveloperSettings = ::onOpenDeveloperSettings,
onReportProblem = ::onReportProblem,
) )
} }
} }

View file

@ -16,6 +16,7 @@
package io.element.android.features.onboarding.impl package io.element.android.features.onboarding.impl
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -65,6 +66,7 @@ fun OnBoardingView(
onSignIn: () -> Unit, onSignIn: () -> Unit,
onCreateAccount: () -> Unit, onCreateAccount: () -> Unit,
onOpenDeveloperSettings: () -> Unit, onOpenDeveloperSettings: () -> Unit,
onReportProblem: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
OnBoardingPage( OnBoardingPage(
@ -81,6 +83,7 @@ fun OnBoardingView(
onSignInWithQrCode = onSignInWithQrCode, onSignInWithQrCode = onSignInWithQrCode,
onSignIn = onSignIn, onSignIn = onSignIn,
onCreateAccount = onCreateAccount, onCreateAccount = onCreateAccount,
onReportProblem = onReportProblem,
) )
} }
) )
@ -154,6 +157,7 @@ private fun OnBoardingButtons(
onSignInWithQrCode: () -> Unit, onSignInWithQrCode: () -> Unit,
onSignIn: () -> Unit, onSignIn: () -> Unit,
onCreateAccount: () -> Unit, onCreateAccount: () -> Unit,
onReportProblem: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
ButtonColumnMolecule(modifier = modifier) { ButtonColumnMolecule(modifier = modifier) {
@ -187,6 +191,15 @@ private fun OnBoardingButtons(
) )
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Add a report problem text button. Use a Text since we need a special theme here.
Text(
modifier = Modifier
.padding(8.dp)
.clickable(onClick = onReportProblem),
text = stringResource(id = CommonStrings.common_report_a_problem),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
} }
} }
@ -200,6 +213,7 @@ internal fun OnBoardingScreenPreview(
onSignInWithQrCode = {}, onSignInWithQrCode = {},
onSignIn = {}, onSignIn = {},
onCreateAccount = {}, onCreateAccount = {},
onOpenDeveloperSettings = {} onOpenDeveloperSettings = {},
onReportProblem = {},
) )
} }

View file

@ -82,6 +82,7 @@ dependencies {
testImplementation(projects.libraries.indicator.impl) testImplementation(projects.libraries.indicator.impl)
testImplementation(projects.features.logout.impl) testImplementation(projects.features.logout.impl)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.features.analytics.impl) testImplementation(projects.features.analytics.impl)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
} }

View file

@ -21,6 +21,7 @@ import io.element.android.compound.theme.Theme
sealed interface AdvancedSettingsEvents { sealed interface AdvancedSettingsEvents {
data class SetRichTextEditorEnabled(val enabled: Boolean) : AdvancedSettingsEvents data class SetRichTextEditorEnabled(val enabled: Boolean) : AdvancedSettingsEvents
data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents
data class SetSendPublicReadReceiptsEnabled(val enabled: Boolean) : AdvancedSettingsEvents
data object ChangeTheme : AdvancedSettingsEvents data object ChangeTheme : AdvancedSettingsEvents
data object CancelChangeTheme : AdvancedSettingsEvents data object CancelChangeTheme : AdvancedSettingsEvents
data class SetTheme(val theme: Theme) : AdvancedSettingsEvents data class SetTheme(val theme: Theme) : AdvancedSettingsEvents

View file

@ -25,40 +25,48 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.compound.theme.Theme import io.element.android.compound.theme.Theme
import io.element.android.compound.theme.mapToTheme import io.element.android.compound.theme.mapToTheme
import io.element.android.features.preferences.api.store.PreferencesStore import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class AdvancedSettingsPresenter @Inject constructor( class AdvancedSettingsPresenter @Inject constructor(
private val preferencesStore: PreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val sessionPreferencesStore: SessionPreferencesStore,
) : Presenter<AdvancedSettingsState> { ) : Presenter<AdvancedSettingsState> {
@Composable @Composable
override fun present(): AdvancedSettingsState { override fun present(): AdvancedSettingsState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val isRichTextEditorEnabled by preferencesStore val isRichTextEditorEnabled by appPreferencesStore
.isRichTextEditorEnabledFlow() .isRichTextEditorEnabledFlow()
.collectAsState(initial = false) .collectAsState(initial = false)
val isDeveloperModeEnabled by preferencesStore val isDeveloperModeEnabled by appPreferencesStore
.isDeveloperModeEnabledFlow() .isDeveloperModeEnabledFlow()
.collectAsState(initial = false) .collectAsState(initial = false)
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore
.isSendPublicReadReceiptsEnabled()
.collectAsState(initial = true)
val theme by remember { val theme by remember {
preferencesStore.getThemeFlow().mapToTheme() appPreferencesStore.getThemeFlow().mapToTheme()
} }
.collectAsState(initial = Theme.System) .collectAsState(initial = Theme.System)
var showChangeThemeDialog by remember { mutableStateOf(false) } var showChangeThemeDialog by remember { mutableStateOf(false) }
fun handleEvents(event: AdvancedSettingsEvents) { fun handleEvents(event: AdvancedSettingsEvents) {
when (event) { when (event) {
is AdvancedSettingsEvents.SetRichTextEditorEnabled -> localCoroutineScope.launch { is AdvancedSettingsEvents.SetRichTextEditorEnabled -> localCoroutineScope.launch {
preferencesStore.setRichTextEditorEnabled(event.enabled) appPreferencesStore.setRichTextEditorEnabled(event.enabled)
} }
is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch { is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch {
preferencesStore.setDeveloperModeEnabled(event.enabled) appPreferencesStore.setDeveloperModeEnabled(event.enabled)
}
is AdvancedSettingsEvents.SetSendPublicReadReceiptsEnabled -> localCoroutineScope.launch {
sessionPreferencesStore.setSendPublicReadReceipts(event.enabled)
} }
AdvancedSettingsEvents.CancelChangeTheme -> showChangeThemeDialog = false AdvancedSettingsEvents.CancelChangeTheme -> showChangeThemeDialog = false
AdvancedSettingsEvents.ChangeTheme -> showChangeThemeDialog = true AdvancedSettingsEvents.ChangeTheme -> showChangeThemeDialog = true
is AdvancedSettingsEvents.SetTheme -> localCoroutineScope.launch { is AdvancedSettingsEvents.SetTheme -> localCoroutineScope.launch {
preferencesStore.setTheme(event.theme.name) appPreferencesStore.setTheme(event.theme.name)
showChangeThemeDialog = false showChangeThemeDialog = false
} }
} }
@ -67,6 +75,7 @@ class AdvancedSettingsPresenter @Inject constructor(
return AdvancedSettingsState( return AdvancedSettingsState(
isRichTextEditorEnabled = isRichTextEditorEnabled, isRichTextEditorEnabled = isRichTextEditorEnabled,
isDeveloperModeEnabled = isDeveloperModeEnabled, isDeveloperModeEnabled = isDeveloperModeEnabled,
isSendPublicReadReceiptsEnabled = isSendPublicReadReceiptsEnabled,
theme = theme, theme = theme,
showChangeThemeDialog = showChangeThemeDialog, showChangeThemeDialog = showChangeThemeDialog,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }

View file

@ -21,6 +21,7 @@ import io.element.android.compound.theme.Theme
data class AdvancedSettingsState( data class AdvancedSettingsState(
val isRichTextEditorEnabled: Boolean, val isRichTextEditorEnabled: Boolean,
val isDeveloperModeEnabled: Boolean, val isDeveloperModeEnabled: Boolean,
val isSendPublicReadReceiptsEnabled: Boolean,
val theme: Theme, val theme: Theme,
val showChangeThemeDialog: Boolean, val showChangeThemeDialog: Boolean,
val eventSink: (AdvancedSettingsEvents) -> Unit val eventSink: (AdvancedSettingsEvents) -> Unit

View file

@ -26,16 +26,19 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
aAdvancedSettingsState(isRichTextEditorEnabled = true), aAdvancedSettingsState(isRichTextEditorEnabled = true),
aAdvancedSettingsState(isDeveloperModeEnabled = true), aAdvancedSettingsState(isDeveloperModeEnabled = true),
aAdvancedSettingsState(showChangeThemeDialog = true), aAdvancedSettingsState(showChangeThemeDialog = true),
aAdvancedSettingsState(isSendPublicReadReceiptsEnabled = true),
) )
} }
fun aAdvancedSettingsState( fun aAdvancedSettingsState(
isRichTextEditorEnabled: Boolean = false, isRichTextEditorEnabled: Boolean = false,
isDeveloperModeEnabled: Boolean = false, isDeveloperModeEnabled: Boolean = false,
isSendPublicReadReceiptsEnabled: Boolean = false,
showChangeThemeDialog: Boolean = false, showChangeThemeDialog: Boolean = false,
) = AdvancedSettingsState( ) = AdvancedSettingsState(
isRichTextEditorEnabled = isRichTextEditorEnabled, isRichTextEditorEnabled = isRichTextEditorEnabled,
isDeveloperModeEnabled = isDeveloperModeEnabled, isDeveloperModeEnabled = isDeveloperModeEnabled,
isSendPublicReadReceiptsEnabled = isSendPublicReadReceiptsEnabled,
theme = Theme.System, theme = Theme.System,
showChangeThemeDialog = showChangeThemeDialog, showChangeThemeDialog = showChangeThemeDialog,
eventSink = {} eventSink = {}

View file

@ -66,8 +66,8 @@ fun AdvancedSettingsView(
}, },
trailingContent = ListItemContent.Switch( trailingContent = ListItemContent.Switch(
checked = state.isRichTextEditorEnabled, checked = state.isRichTextEditorEnabled,
onChange = { state.eventSink(AdvancedSettingsEvents.SetRichTextEditorEnabled(it)) },
), ),
onClick = { state.eventSink(AdvancedSettingsEvents.SetRichTextEditorEnabled(!state.isRichTextEditorEnabled)) }
) )
ListItem( ListItem(
headlineContent = { headlineContent = {
@ -78,8 +78,20 @@ fun AdvancedSettingsView(
}, },
trailingContent = ListItemContent.Switch( trailingContent = ListItemContent.Switch(
checked = state.isDeveloperModeEnabled, checked = state.isDeveloperModeEnabled,
onChange = { state.eventSink(AdvancedSettingsEvents.SetDeveloperModeEnabled(it)) },
), ),
onClick = { state.eventSink(AdvancedSettingsEvents.SetDeveloperModeEnabled(!state.isDeveloperModeEnabled)) }
)
ListItem(
headlineContent = {
Text(text = stringResource(id = R.string.screen_advanced_settings_send_read_receipts))
},
supportingContent = {
Text(text = stringResource(id = R.string.screen_advanced_settings_send_read_receipts_description))
},
trailingContent = ListItemContent.Switch(
checked = state.isSendPublicReadReceiptsEnabled,
),
onClick = { state.eventSink(AdvancedSettingsEvents.SetSendPublicReadReceiptsEnabled(!state.isSendPublicReadReceiptsEnabled)) }
) )
} }

View file

@ -28,7 +28,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.runtime.snapshots.SnapshotStateMap
import io.element.android.appconfig.ElementCallConfig import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.preferences.api.store.PreferencesStore import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter
@ -51,7 +51,7 @@ class DeveloperSettingsPresenter @Inject constructor(
private val computeCacheSizeUseCase: ComputeCacheSizeUseCase, private val computeCacheSizeUseCase: ComputeCacheSizeUseCase,
private val clearCacheUseCase: ClearCacheUseCase, private val clearCacheUseCase: ClearCacheUseCase,
private val rageshakePresenter: RageshakePreferencesPresenter, private val rageshakePresenter: RageshakePreferencesPresenter,
private val preferencesStore: PreferencesStore, private val appPreferencesStore: AppPreferencesStore,
) : Presenter<DeveloperSettingsState> { ) : Presenter<DeveloperSettingsState> {
@Composable @Composable
override fun present(): DeveloperSettingsState { override fun present(): DeveloperSettingsState {
@ -69,7 +69,7 @@ class DeveloperSettingsPresenter @Inject constructor(
val clearCacheAction = remember { val clearCacheAction = remember {
mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized) mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized)
} }
val customElementCallBaseUrl by preferencesStore val customElementCallBaseUrl by appPreferencesStore
.getCustomElementCallBaseUrlFlow() .getCustomElementCallBaseUrlFlow()
.collectAsState(initial = null) .collectAsState(initial = null)
@ -100,7 +100,7 @@ class DeveloperSettingsPresenter @Inject constructor(
is DeveloperSettingsEvents.SetCustomElementCallBaseUrl -> coroutineScope.launch { is DeveloperSettingsEvents.SetCustomElementCallBaseUrl -> coroutineScope.launch {
// If the URL is either empty or the default one, we want to save 'null' to remove the custom URL // If the URL is either empty or the default one, we want to save 'null' to remove the custom URL
val urlToSave = event.baseUrl.takeIf { !it.isNullOrEmpty() && it != ElementCallConfig.DEFAULT_BASE_URL } val urlToSave = event.baseUrl.takeIf { !it.isNullOrEmpty() && it != ElementCallConfig.DEFAULT_BASE_URL }
preferencesStore.setCustomElementCallBaseUrl(urlToSave) appPreferencesStore.setCustomElementCallBaseUrl(urlToSave)
} }
DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction) DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction)
} }

View file

@ -21,7 +21,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails import io.element.android.libraries.matrix.ui.components.aRoomSummaryDetails
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
open class EditDefaultNotificationSettingStateProvider : PreviewParameterProvider<EditDefaultNotificationSettingState> { open class EditDefaultNotificationSettingStateProvider : PreviewParameterProvider<EditDefaultNotificationSettingState> {
@ -49,14 +49,12 @@ private fun anEditDefaultNotificationSettingsState(
) )
private fun aRoomSummary() = RoomSummary.Filled( private fun aRoomSummary() = RoomSummary.Filled(
RoomSummaryDetails( aRoomSummaryDetails(
roomId = RoomId("!roomId:domain"), roomId = RoomId("!roomId:domain"),
name = "Room", name = "Room",
avatarURLString = null, avatarUrl = null,
isDirect = false, isDirect = false,
lastMessage = null, lastMessage = null,
lastMessageTimestamp = null,
unreadNotificationCount = 0,
notificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, notificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
) )
) )

View file

@ -84,7 +84,7 @@ fun EditDefaultNotificationSettingView(
if (state.roomsWithUserDefinedMode.isNotEmpty()) { if (state.roomsWithUserDefinedMode.isNotEmpty()) {
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_edit_custom_settings_section_title)) { PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_edit_custom_settings_section_title)) {
state.roomsWithUserDefinedMode.forEach { summary -> state.roomsWithUserDefinedMode.forEach { summary ->
val subtitle = when (summary.details.notificationMode) { val subtitle = when (summary.details.userDefinedNotificationMode) {
RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_notification_settings_edit_mode_all_messages) RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_notification_settings_edit_mode_all_messages)
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> { RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> {
stringResource(id = R.string.screen_notification_settings_edit_mode_mentions_and_keywords) stringResource(id = R.string.screen_notification_settings_edit_mode_mentions_and_keywords)
@ -95,7 +95,7 @@ fun EditDefaultNotificationSettingView(
val avatarData = AvatarData( val avatarData = AvatarData(
id = summary.identifier(), id = summary.identifier(),
name = summary.details.name, name = summary.details.name,
url = summary.details.avatarURLString, url = summary.details.avatarUrl,
size = AvatarSize.CustomRoomNotificationSetting, size = AvatarSize.CustomRoomNotificationSetting,
) )
ListItem( ListItem(

View file

@ -33,16 +33,16 @@ class DefaultVersionFormatter @Inject constructor(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
) : VersionFormatter { ) : VersionFormatter {
override fun get(): String { override fun get(): String {
return stringProvider.getString( val base = stringProvider.getString(
CommonStrings.settings_version_number, CommonStrings.settings_version_number,
buildMeta.versionName, buildMeta.versionName,
buildMeta.versionCode.toString() buildMeta.versionCode.toString()
) )
} return if (buildMeta.gitBranchName == "main") {
} base
} else {
class FakeVersionFormatter : VersionFormatter { // In case of a build not from main, we display the branch name and the revision
override fun get(): String { "$base\n${buildMeta.gitBranchName}\n${buildMeta.gitRevision}"
return "A Version" }
} }
} }

View file

@ -6,6 +6,8 @@
<string name="screen_advanced_settings_developer_mode">"Vývojářský režim"</string> <string name="screen_advanced_settings_developer_mode">"Vývojářský režim"</string>
<string name="screen_advanced_settings_developer_mode_description">"Povolením získáte přístup k funkcím a funkcím pro vývojáře."</string> <string name="screen_advanced_settings_developer_mode_description">"Povolením získáte přístup k funkcím a funkcím pro vývojáře."</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Vypněte editor formátovaného textu pro ruční zadání Markdown."</string> <string name="screen_advanced_settings_rich_text_editor_description">"Vypněte editor formátovaného textu pro ruční zadání Markdown."</string>
<string name="screen_advanced_settings_send_read_receipts">"Potvrzení o přečtení"</string>
<string name="screen_advanced_settings_send_read_receipts_description">"Pokud je vypnuto, potvrzení o přečtení se nikomu neodesílají. Stále budete dostávat potvrzení o přečtení od ostatních uživatelů."</string>
<string name="screen_advanced_settings_view_source_description">"Povolit možnost zobrazení zdroje zprávy na časové ose."</string> <string name="screen_advanced_settings_view_source_description">"Povolit možnost zobrazení zdroje zprávy na časové ose."</string>
<string name="screen_edit_profile_display_name">"Zobrazované jméno"</string> <string name="screen_edit_profile_display_name">"Zobrazované jméno"</string>
<string name="screen_edit_profile_display_name_placeholder">"Vaše zobrazované jméno"</string> <string name="screen_edit_profile_display_name_placeholder">"Vaše zobrazované jméno"</string>

View file

@ -6,6 +6,8 @@
<string name="screen_advanced_settings_developer_mode">"Mode développeur"</string> <string name="screen_advanced_settings_developer_mode">"Mode développeur"</string>
<string name="screen_advanced_settings_developer_mode_description">"Activer pour pouvoir accéder aux fonctionnalités destinées aux développeurs."</string> <string name="screen_advanced_settings_developer_mode_description">"Activer pour pouvoir accéder aux fonctionnalités destinées aux développeurs."</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Désactivez léditeur de texte enrichi pour saisir manuellement du Markdown."</string> <string name="screen_advanced_settings_rich_text_editor_description">"Désactivez léditeur de texte enrichi pour saisir manuellement du Markdown."</string>
<string name="screen_advanced_settings_send_read_receipts">"Accusés de lecture"</string>
<string name="screen_advanced_settings_send_read_receipts_description">"En cas de désactivation, vos accusés de lecture ne seront pas envoyés aux autres membres. Vous verrez toujours les accusés des autres membres."</string>
<string name="screen_advanced_settings_view_source_description">"Activer cette option pour pouvoir voir la source des messages dans la discussion."</string> <string name="screen_advanced_settings_view_source_description">"Activer cette option pour pouvoir voir la source des messages dans la discussion."</string>
<string name="screen_edit_profile_display_name">"Pseudonyme"</string> <string name="screen_edit_profile_display_name">"Pseudonyme"</string>
<string name="screen_edit_profile_display_name_placeholder">"Votre pseudonyme"</string> <string name="screen_edit_profile_display_name_placeholder">"Votre pseudonyme"</string>

View file

@ -6,6 +6,8 @@
<string name="screen_advanced_settings_developer_mode">"Vývojársky režim"</string> <string name="screen_advanced_settings_developer_mode">"Vývojársky režim"</string>
<string name="screen_advanced_settings_developer_mode_description">"Umožniť prístup k možnostiam a funkciám pre vývojárov."</string> <string name="screen_advanced_settings_developer_mode_description">"Umožniť prístup k možnostiam a funkciám pre vývojárov."</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Vypnite rozšírený textový editor na ručné písanie Markdown."</string> <string name="screen_advanced_settings_rich_text_editor_description">"Vypnite rozšírený textový editor na ručné písanie Markdown."</string>
<string name="screen_advanced_settings_send_read_receipts">"Potvrdenia o prečítaní"</string>
<string name="screen_advanced_settings_send_read_receipts_description">"Ak je táto funkcia vypnutá, vaše potvrdenia o prečítaní sa nebudú nikomu odosielať. Stále budete dostávať potvrdenia o prečítaní od ostatných používateľov."</string>
<string name="screen_advanced_settings_view_source_description">"Povoliť možnosť zobrazenia zdroja správy na časovej osi."</string> <string name="screen_advanced_settings_view_source_description">"Povoliť možnosť zobrazenia zdroja správy na časovej osi."</string>
<string name="screen_edit_profile_display_name">"Zobrazované meno"</string> <string name="screen_edit_profile_display_name">"Zobrazované meno"</string>
<string name="screen_edit_profile_display_name_placeholder">"Vaše zobrazované meno"</string> <string name="screen_edit_profile_display_name_placeholder">"Vaše zobrazované meno"</string>

View file

@ -6,6 +6,8 @@
<string name="screen_advanced_settings_developer_mode">"Developer mode"</string> <string name="screen_advanced_settings_developer_mode">"Developer mode"</string>
<string name="screen_advanced_settings_developer_mode_description">"Enable to have access to features and functionality for developers."</string> <string name="screen_advanced_settings_developer_mode_description">"Enable to have access to features and functionality for developers."</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Disable the rich text editor to type Markdown manually."</string> <string name="screen_advanced_settings_rich_text_editor_description">"Disable the rich text editor to type Markdown manually."</string>
<string name="screen_advanced_settings_send_read_receipts">"Read receipts"</string>
<string name="screen_advanced_settings_send_read_receipts_description">"If turned off, your read receipts won\'t be sent to anyone. You will still receive read receipts from other users."</string>
<string name="screen_advanced_settings_view_source_description">"Enable option to view message source in the timeline."</string> <string name="screen_advanced_settings_view_source_description">"Enable option to view message source in the timeline."</string>
<string name="screen_edit_profile_display_name">"Display name"</string> <string name="screen_edit_profile_display_name">"Display name"</string>
<string name="screen_edit_profile_display_name_placeholder">"Your display name"</string> <string name="screen_edit_profile_display_name_placeholder">"Your display name"</string>

View file

@ -21,7 +21,8 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.compound.theme.Theme import io.element.android.compound.theme.Theme
import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore import io.element.android.libraries.featureflag.test.InMemoryAppPreferencesStore
import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -34,8 +35,7 @@ class AdvancedSettingsPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val store = InMemoryPreferencesStore() val presenter = createAdvancedSettingsPresenter()
val presenter = AdvancedSettingsPresenter(store)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -43,14 +43,14 @@ class AdvancedSettingsPresenterTest {
assertThat(initialState.isDeveloperModeEnabled).isFalse() assertThat(initialState.isDeveloperModeEnabled).isFalse()
assertThat(initialState.isRichTextEditorEnabled).isFalse() assertThat(initialState.isRichTextEditorEnabled).isFalse()
assertThat(initialState.showChangeThemeDialog).isFalse() assertThat(initialState.showChangeThemeDialog).isFalse()
assertThat(initialState.isSendPublicReadReceiptsEnabled).isTrue()
assertThat(initialState.theme).isEqualTo(Theme.System) assertThat(initialState.theme).isEqualTo(Theme.System)
} }
} }
@Test @Test
fun `present - developer mode on off`() = runTest { fun `present - developer mode on off`() = runTest {
val store = InMemoryPreferencesStore() val presenter = createAdvancedSettingsPresenter()
val presenter = AdvancedSettingsPresenter(store)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -65,8 +65,7 @@ class AdvancedSettingsPresenterTest {
@Test @Test
fun `present - rich text editor on off`() = runTest { fun `present - rich text editor on off`() = runTest {
val store = InMemoryPreferencesStore() val presenter = createAdvancedSettingsPresenter()
val presenter = AdvancedSettingsPresenter(store)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -79,10 +78,24 @@ class AdvancedSettingsPresenterTest {
} }
} }
@Test
fun `present - send public read receipts off on`() = runTest {
val presenter = createAdvancedSettingsPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitLastSequentialItem()
assertThat(initialState.isSendPublicReadReceiptsEnabled).isTrue()
initialState.eventSink.invoke(AdvancedSettingsEvents.SetSendPublicReadReceiptsEnabled(false))
assertThat(awaitItem().isSendPublicReadReceiptsEnabled).isFalse()
initialState.eventSink.invoke(AdvancedSettingsEvents.SetSendPublicReadReceiptsEnabled(true))
assertThat(awaitItem().isSendPublicReadReceiptsEnabled).isTrue()
}
}
@Test @Test
fun `present - change theme`() = runTest { fun `present - change theme`() = runTest {
val store = InMemoryPreferencesStore() val presenter = createAdvancedSettingsPresenter()
val presenter = AdvancedSettingsPresenter(store)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -102,4 +115,12 @@ class AdvancedSettingsPresenterTest {
assertThat(withNewTheme.theme).isEqualTo(Theme.Light) assertThat(withNewTheme.theme).isEqualTo(Theme.Light)
} }
} }
private fun createAdvancedSettingsPresenter(
appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
) = AdvancedSettingsPresenter(
appPreferencesStore = appPreferencesStore,
sessionPreferencesStore = sessionPreferencesStore,
)
} }

View file

@ -29,7 +29,7 @@ import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataSto
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore import io.element.android.libraries.featureflag.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -114,7 +114,7 @@ class DeveloperSettingsPresenterTest {
@Test @Test
fun `present - custom element call base url`() = runTest { fun `present - custom element call base url`() = runTest {
val preferencesStore = InMemoryPreferencesStore() val preferencesStore = InMemoryAppPreferencesStore()
val presenter = createDeveloperSettingsPresenter(preferencesStore = preferencesStore) val presenter = createDeveloperSettingsPresenter(preferencesStore = preferencesStore)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -149,14 +149,14 @@ class DeveloperSettingsPresenterTest {
cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(), cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(),
clearCacheUseCase: FakeClearCacheUseCase = FakeClearCacheUseCase(), clearCacheUseCase: FakeClearCacheUseCase = FakeClearCacheUseCase(),
rageshakePresenter: DefaultRageshakePreferencesPresenter = DefaultRageshakePreferencesPresenter(FakeRageShake(), FakeRageshakeDataStore()), rageshakePresenter: DefaultRageshakePreferencesPresenter = DefaultRageshakePreferencesPresenter(FakeRageShake(), FakeRageshakeDataStore()),
preferencesStore: InMemoryPreferencesStore = InMemoryPreferencesStore(), preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
): DeveloperSettingsPresenter { ): DeveloperSettingsPresenter {
return DeveloperSettingsPresenter( return DeveloperSettingsPresenter(
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
computeCacheSizeUseCase = cacheSizeUseCase, computeCacheSizeUseCase = cacheSizeUseCase,
clearCacheUseCase = clearCacheUseCase, clearCacheUseCase = clearCacheUseCase,
rageshakePresenter = rageshakePresenter, rageshakePresenter = rageshakePresenter,
preferencesStore = preferencesStore, appPreferencesStore = preferencesStore,
) )
} }
} }

View file

@ -29,7 +29,7 @@ import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
@ -72,11 +72,11 @@ class EditDefaultNotificationSettingsPresenterTests {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
roomListService.postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetail(notificationMode = RoomNotificationMode.ALL_MESSAGES)))) roomListService.postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetails(notificationMode = RoomNotificationMode.ALL_MESSAGES))))
val loadedState = consumeItemsUntilPredicate { state -> val loadedState = consumeItemsUntilPredicate { state ->
state.roomsWithUserDefinedMode.any { it.details.notificationMode == RoomNotificationMode.ALL_MESSAGES } state.roomsWithUserDefinedMode.any { it.details.userDefinedNotificationMode == RoomNotificationMode.ALL_MESSAGES }
}.last() }.last()
assertThat(loadedState.roomsWithUserDefinedMode.any { it.details.notificationMode == RoomNotificationMode.ALL_MESSAGES }).isTrue() assertThat(loadedState.roomsWithUserDefinedMode.any { it.details.userDefinedNotificationMode == RoomNotificationMode.ALL_MESSAGES }).isTrue()
} }
} }

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2024 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.preferences.impl.root
class FakeVersionFormatter : VersionFormatter {
override fun get(): String {
return "A Version"
}
}

View file

@ -0,0 +1,50 @@
/*
* Copyright (c) 2024 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.preferences.impl.root
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test
class VersionFormatterTest {
@Test
fun `version formatter should return simplified version for other branch`() = runTest {
val sut = DefaultVersionFormatter(
stringProvider = FakeStringProvider(defaultResult = VERSION),
buildMeta = aBuildMeta(gitBranchName = "main")
)
assertThat(sut.get()).isEqualTo(VERSION)
}
@Test
fun `version formatter should return simplified version for main branch`() = runTest {
val sut = DefaultVersionFormatter(
stringProvider = FakeStringProvider(defaultResult = VERSION),
buildMeta = aBuildMeta(
gitBranchName = "branch",
gitRevision = "1234567890",
)
)
assertThat(sut.get()).isEqualTo("$VERSION\nbranch\n1234567890")
}
companion object {
const val VERSION = "version"
}
}

View file

@ -31,5 +31,6 @@ interface BugReportEntryPoint : FeatureEntryPoint {
interface Callback : Plugin { interface Callback : Plugin {
fun onBugReportSent() fun onBugReportSent()
fun onViewLogs(basePath: String)
} }
} }

View file

@ -18,6 +18,8 @@ package io.element.android.features.rageshake.api.detection
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -73,9 +75,10 @@ private fun TakeScreenshot(
onScreenshotTaken: (ImageResult) -> Unit onScreenshotTaken: (ImageResult) -> Unit
) { ) {
val view = LocalView.current val view = LocalView.current
val latestOnScreenshotTaken by rememberUpdatedState(onScreenshotTaken)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
view.screenshot { view.screenshot {
onScreenshotTaken(it) latestOnScreenshotTaken(it)
} }
} }
} }

View file

@ -52,4 +52,9 @@ interface BugReporter {
* Set the current tracing filter. * Set the current tracing filter.
*/ */
fun setCurrentTracingFilter(tracingFilter: String) fun setCurrentTracingFilter(tracingFilter: String)
/**
* Save the logcat.
*/
fun saveLogCat()
} }

View file

@ -28,6 +28,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.libraries.androidutils.system.toast import io.element.android.libraries.androidutils.system.toast
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -37,7 +38,12 @@ class BugReportNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: BugReportPresenter, private val presenter: BugReportPresenter,
private val bugReporter: BugReporter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private fun onViewLogs(basePath: String) {
plugins<BugReportEntryPoint.Callback>().forEach { it.onViewLogs(basePath) }
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
@ -50,6 +56,11 @@ class BugReportNode @AssistedInject constructor(
activity?.toast(CommonStrings.common_report_submitted) activity?.toast(CommonStrings.common_report_submitted)
onDone() onDone()
}, },
onViewLogs = {
// Force a logcat dump
bugReporter.saveLogCat()
onViewLogs(bugReporter.logDirectory().absolutePath)
}
) )
} }

View file

@ -40,9 +40,11 @@ import io.element.android.features.rageshake.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.form.textFieldState import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.components.preferences.PreferenceDivider
import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.components.preferences.PreferenceRow import io.element.android.libraries.designsystem.components.preferences.PreferenceRow
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.debugPlaceholderBackground import io.element.android.libraries.designsystem.preview.debugPlaceholderBackground
@ -55,6 +57,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun BugReportView( fun BugReportView(
state: BugReportState, state: BugReportState,
onViewLogs: () -> Unit,
onDone: () -> Unit, onDone: () -> Unit,
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -97,6 +100,13 @@ fun BugReportView(
) )
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
PreferenceDivider()
PreferenceText(
title = stringResource(id = R.string.screen_bug_report_view_logs),
enabled = isFormEnabled,
onClick = onViewLogs,
)
PreferenceDivider()
PreferenceSwitch( PreferenceSwitch(
isChecked = state.formState.sendLogs, isChecked = state.formState.sendLogs,
onCheckedChange = { eventSink(BugReportEvents.SetSendLog(it)) }, onCheckedChange = { eventSink(BugReportEvents.SetSendLog(it)) },
@ -169,5 +179,6 @@ internal fun BugReportViewPreview(@PreviewParameter(BugReportStateProvider::clas
state = state, state = state,
onDone = {}, onDone = {},
onBackPressed = {}, onBackPressed = {},
onViewLogs = {},
) )
} }

View file

@ -94,6 +94,8 @@ class DefaultBugReporter @Inject constructor(
private val logcatCommandDebug = arrayOf("logcat", "-d", "-v", "threadtime", "*:*") private val logcatCommandDebug = arrayOf("logcat", "-d", "-v", "threadtime", "*:*")
private var currentTracingFilter: String? = null private var currentTracingFilter: String? = null
private val logCatErrFile = File(logDirectory().absolutePath, LOG_CAT_FILENAME)
override suspend fun sendBugReport( override suspend fun sendBugReport(
withDevicesLogs: Boolean, withDevicesLogs: Boolean,
withCrashLogs: Boolean, withCrashLogs: Boolean,
@ -130,8 +132,8 @@ class DefaultBugReporter @Inject constructor(
} }
if (!isCancelled && (withCrashLogs || withDevicesLogs)) { if (!isCancelled && (withCrashLogs || withDevicesLogs)) {
val gzippedLogcat = saveLogCat() saveLogCat()
val gzippedLogcat = compressFile(logCatErrFile)
if (null != gzippedLogcat) { if (null != gzippedLogcat) {
if (gzippedFiles.size == 0) { if (gzippedFiles.size == 0) {
gzippedFiles.add(gzippedLogcat) gzippedFiles.add(gzippedLogcat)
@ -199,6 +201,9 @@ class DefaultBugReporter @Inject constructor(
// add some github labels // add some github labels
builder.addFormDataPart("label", buildMeta.versionName) builder.addFormDataPart("label", buildMeta.versionName)
builder.addFormDataPart("label", buildMeta.flavorDescription)
builder.addFormDataPart("branch_name", buildMeta.gitBranchName)
if (crashCallStack.isNotEmpty() && withCrashLogs) { if (crashCallStack.isNotEmpty() && withCrashLogs) {
builder.addFormDataPart("label", "crash") builder.addFormDataPart("label", "crash")
} }
@ -320,7 +325,9 @@ class DefaultBugReporter @Inject constructor(
} }
override fun logDirectory(): File { override fun logDirectory(): File {
return File(context.cacheDir, LOG_DIRECTORY_NAME) return File(context.cacheDir, LOG_DIRECTORY_NAME).apply {
mkdirs()
}
} }
override fun cleanLogDirectoryIfNeeded() { override fun cleanLogDirectoryIfNeeded() {
@ -380,30 +387,19 @@ class DefaultBugReporter @Inject constructor(
* *
* @return the file if the operation succeeds * @return the file if the operation succeeds
*/ */
private fun saveLogCat(): File? { override fun saveLogCat() {
val logCatErrFile = File(context.cacheDir.absolutePath, LOG_CAT_FILENAME)
if (logCatErrFile.exists()) { if (logCatErrFile.exists()) {
logCatErrFile.safeDelete() logCatErrFile.safeDelete()
} }
try { try {
logCatErrFile.writer().use { logCatErrFile.writer().use {
getLogCatError(it) getLogCatError(it)
} }
return compressFile(logCatErrFile)
} catch (error: OutOfMemoryError) { } catch (error: OutOfMemoryError) {
Timber.e(error, "## saveLogCat() : fail to write logcat OOM") Timber.e(error, "## saveLogCat() : fail to write logcat OOM")
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "## saveLogCat() : fail to write logcat") Timber.e(e, "## saveLogCat() : fail to write logcat")
} finally {
if (logCatErrFile.exists()) {
logCatErrFile.safeDelete()
}
} }
return null
} }
/** /**

View file

@ -11,5 +11,6 @@
<string name="screen_bug_report_include_logs">"Povolit protokoly"</string> <string name="screen_bug_report_include_logs">"Povolit protokoly"</string>
<string name="screen_bug_report_include_screenshot">"Odeslat snímek obrazovky"</string> <string name="screen_bug_report_include_screenshot">"Odeslat snímek obrazovky"</string>
<string name="screen_bug_report_logs_description">"Protokoly budou součástí vaší zprávy, aby se zajistilo že vše funguje správně. Chcete-li odeslat zprávu bez protokolů, vypněte toto nastavení."</string> <string name="screen_bug_report_logs_description">"Protokoly budou součástí vaší zprávy, aby se zajistilo že vše funguje správně. Chcete-li odeslat zprávu bez protokolů, vypněte toto nastavení."</string>
<string name="screen_bug_report_view_logs">"Zobrazit protokoly"</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s havaroval při posledním použití. Chcete se s námi podělit o zprávu o selhání?"</string> <string name="screen_bug_report_rash_logs_alert_title">"%1$s havaroval při posledním použití. Chcete se s námi podělit o zprávu o selhání?"</string>
</resources> </resources>

View file

@ -11,5 +11,6 @@
<string name="screen_bug_report_include_logs">"Разрешить ведение журналов"</string> <string name="screen_bug_report_include_logs">"Разрешить ведение журналов"</string>
<string name="screen_bug_report_include_screenshot">"Отправить снимок экрана"</string> <string name="screen_bug_report_include_screenshot">"Отправить снимок экрана"</string>
<string name="screen_bug_report_logs_description">"Чтобы убедиться, что все работает правильно, в сообщение будут включены журналы. Чтобы отправить сообщение без журналов, отключите эту настройку."</string> <string name="screen_bug_report_logs_description">"Чтобы убедиться, что все работает правильно, в сообщение будут включены журналы. Чтобы отправить сообщение без журналов, отключите эту настройку."</string>
<string name="screen_bug_report_view_logs">"Просмотр журналов"</string>
<string name="screen_bug_report_rash_logs_alert_title">"При последнем использовании %1$s произошел сбой. Хотите поделиться отчетом о сбое?"</string> <string name="screen_bug_report_rash_logs_alert_title">"При последнем использовании %1$s произошел сбой. Хотите поделиться отчетом о сбое?"</string>
</resources> </resources>

View file

@ -11,5 +11,6 @@
<string name="screen_bug_report_include_logs">"Povoliť záznamy"</string> <string name="screen_bug_report_include_logs">"Povoliť záznamy"</string>
<string name="screen_bug_report_include_screenshot">"Odoslať snímku obrazovky"</string> <string name="screen_bug_report_include_screenshot">"Odoslať snímku obrazovky"</string>
<string name="screen_bug_report_logs_description">"K vašej správe budú priložené záznamy o chybe, aby sme sa uistili, že všetko funguje správne. Ak chcete odoslať správu bez záznamov o chybe, vypnite toto nastavenie."</string> <string name="screen_bug_report_logs_description">"K vašej správe budú priložené záznamy o chybe, aby sme sa uistili, že všetko funguje správne. Ak chcete odoslať správu bez záznamov o chybe, vypnite toto nastavenie."</string>
<string name="screen_bug_report_view_logs">"Zobraziť záznamy"</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s zlyhal pri poslednom použití. Chcete zdieľať správu o páde s našim tímom?"</string> <string name="screen_bug_report_rash_logs_alert_title">"%1$s zlyhal pri poslednom použití. Chcete zdieľať správu o páde s našim tímom?"</string>
</resources> </resources>

View file

@ -11,5 +11,6 @@
<string name="screen_bug_report_include_logs">"Allow logs"</string> <string name="screen_bug_report_include_logs">"Allow logs"</string>
<string name="screen_bug_report_include_screenshot">"Send screenshot"</string> <string name="screen_bug_report_include_screenshot">"Send screenshot"</string>
<string name="screen_bug_report_logs_description">"Logs will be included with your message to make sure that everything is working properly. To send your message without logs, turn off this setting."</string> <string name="screen_bug_report_logs_description">"Logs will be included with your message to make sure that everything is working properly. To send your message without logs, turn off this setting."</string>
<string name="screen_bug_report_view_logs">"View logs"</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s crashed the last time it was used. Would you like to share a crash report with us?"</string> <string name="screen_bug_report_rash_logs_alert_title">"%1$s crashed the last time it was used. Would you like to share a crash report with us?"</string>
</resources> </resources>

View file

@ -63,6 +63,10 @@ class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Succes
override fun setCurrentTracingFilter(tracingFilter: String) { override fun setCurrentTracingFilter(tracingFilter: String) {
// No op // No op
} }
override fun saveLogCat() {
// No op
}
} }
enum class FakeBugReporterMode { enum class FakeBugReporterMode {

View file

@ -48,7 +48,7 @@ class DefaultBugReporterTest {
val sut = createDefaultBugReporter(server) val sut = createDefaultBugReporter(server)
var onUploadCancelledCalled = false var onUploadCancelledCalled = false
var onUploadFailedCalled = false var onUploadFailedCalled = false
var progressValues = mutableListOf<Int>() val progressValues = mutableListOf<Int>()
var onUploadSucceedCalled = false var onUploadSucceedCalled = false
sut.sendBugReport( sut.sendBugReport(
withDevicesLogs = true, withDevicesLogs = true,
@ -80,7 +80,7 @@ class DefaultBugReporterTest {
server.shutdown() server.shutdown()
assertThat(onUploadCancelledCalled).isFalse() assertThat(onUploadCancelledCalled).isFalse()
assertThat(onUploadFailedCalled).isFalse() assertThat(onUploadFailedCalled).isFalse()
assertThat(progressValues.size).isEqualTo(10) assertThat(progressValues.size).isEqualTo(EXPECTED_NUMBER_OF_PROGRESS_VALUE)
assertThat(onUploadSucceedCalled).isTrue() assertThat(onUploadSucceedCalled).isTrue()
} }
@ -97,7 +97,7 @@ class DefaultBugReporterTest {
var onUploadCancelledCalled = false var onUploadCancelledCalled = false
var onUploadFailedCalled = false var onUploadFailedCalled = false
var onUploadFailedReason: String? = null var onUploadFailedReason: String? = null
var progressValues = mutableListOf<Int>() val progressValues = mutableListOf<Int>()
var onUploadSucceedCalled = false var onUploadSucceedCalled = false
sut.sendBugReport( sut.sendBugReport(
withDevicesLogs = true, withDevicesLogs = true,
@ -131,7 +131,7 @@ class DefaultBugReporterTest {
assertThat(onUploadCancelledCalled).isFalse() assertThat(onUploadCancelledCalled).isFalse()
assertThat(onUploadFailedCalled).isTrue() assertThat(onUploadFailedCalled).isTrue()
assertThat(onUploadFailedReason).isEqualTo("An error body") assertThat(onUploadFailedReason).isEqualTo("An error body")
assertThat(progressValues.size).isEqualTo(10) assertThat(progressValues.size).isEqualTo(EXPECTED_NUMBER_OF_PROGRESS_VALUE)
assertThat(onUploadSucceedCalled).isFalse() assertThat(onUploadSucceedCalled).isFalse()
} }
@ -153,4 +153,8 @@ class DefaultBugReporterTest {
bugReporterUrlProvider = { server.url("/") } bugReporterUrlProvider = { server.url("/") }
) )
} }
companion object {
private const val EXPECTED_NUMBER_OF_PROGRESS_VALUE = 12
}
} }

View file

@ -26,11 +26,13 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.Lifecycle
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
@ -63,6 +65,12 @@ class RoomDetailsPresenter @Inject constructor(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val leaveRoomState = leaveRoomPresenter.present() val leaveRoomState = leaveRoomPresenter.present()
val canShowNotificationSettings = remember { mutableStateOf(false) } val canShowNotificationSettings = remember { mutableStateOf(false) }
val roomInfo = room.roomInfoFlow.collectAsState(initial = null).value
val roomAvatar by remember { derivedStateOf { roomInfo?.avatarUrl ?: room.avatarUrl } }
val roomName by remember { derivedStateOf { (roomInfo?.name ?: room.name ?: room.displayName).trim() } }
val roomTopic by remember { derivedStateOf { roomInfo?.topic ?: room.topic } }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
canShowNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings) canShowNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings)
@ -70,7 +78,13 @@ class RoomDetailsPresenter @Inject constructor(
room.updateRoomNotificationSettings() room.updateRoomNotificationSettings()
observeNotificationSettings() observeNotificationSettings()
} }
room.updateMembers() }
// Update room members only when first presenting the node
OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_CREATE) {
scope.launch { room.updateMembers() }
}
} }
val membersState by room.membersStateFlow.collectAsState() val membersState by room.membersStateFlow.collectAsState()
@ -82,8 +96,8 @@ class RoomDetailsPresenter @Inject constructor(
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember) val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
val roomType by getRoomType(dmMember) val roomType by getRoomType(dmMember)
val topicState = remember(canEditTopic, room.topic, roomType) { val topicState = remember(canEditTopic, roomTopic, roomType) {
val topic = room.topic val topic = roomTopic
when { when {
!topic.isNullOrBlank() -> RoomTopicState.ExistingTopic(topic) !topic.isNullOrBlank() -> RoomTopicState.ExistingTopic(topic)
@ -115,9 +129,9 @@ class RoomDetailsPresenter @Inject constructor(
return RoomDetailsState( return RoomDetailsState(
roomId = room.roomId.value, roomId = room.roomId.value,
roomName = room.displayName, roomName = roomName,
roomAlias = room.alias, roomAlias = room.alias,
roomAvatarUrl = room.avatarUrl, roomAvatarUrl = roomAvatar,
roomTopic = topicState, roomTopic = topicState,
memberCount = room.joinedMemberCount, memberCount = room.joinedMemberCount,
isEncrypted = room.isEncrypted, isEncrypted = room.isEncrypted,

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