Merge branch 'develop' into feature/fga/avoid_deadlocks

This commit is contained in:
ganfra 2023-07-25 16:09:24 +02:00
commit cfd43af45c
307 changed files with 2806 additions and 1172 deletions

View file

@ -4,7 +4,7 @@ on:
workflow_dispatch: workflow_dispatch:
pull_request: { } pull_request: { }
push: push:
branches: [ main, develop ] branches: [ develop ]
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
@ -34,13 +34,15 @@ 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.6.1 uses: gradle/gradle-build-action@v2.7.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
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew assembleDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug APKs - name: Upload debug APKs
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
@ -70,8 +72,8 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Compile release sources - name: Compile release sources
run: ./gradlew compileReleaseSources $CI_GRADLE_ARG_PROPERTIES run: ./gradlew compileReleaseSources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Compile nightly sources - name: Compile nightly sources
run: ./gradlew compileNightlySources $CI_GRADLE_ARG_PROPERTIES run: ./gradlew compileNightlySources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Compile samples minimal - name: Compile samples minimal
run: ./gradlew :samples:minimal:assemble $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :samples:minimal:assemble $CI_GRADLE_ARG_PROPERTIES

View file

@ -38,6 +38,8 @@ jobs:
run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES run: ./gradlew assembleDebug $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_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
- name: Upload debug APKs - name: Upload debug APKs
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
@ -47,7 +49,9 @@ jobs:
- uses: mobile-dev-inc/action-maestro-cloud@v1.4.1 - uses: mobile-dev-inc/action-maestro-cloud@v1.4.1
with: with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }} api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
app-file: app/build/outputs/apk/debug/app-universal-debug.apk # 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: app/build/outputs/apk/debug/app-x86_64-debug.apk
env: | env: |
USERNAME=maestroelement USERNAME=maestroelement
PASSWORD=${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }} PASSWORD=${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }}

View file

@ -1,4 +1,4 @@
name: Build and release nightly APK name: Build and release nightly application
on: on:
workflow_dispatch: workflow_dispatch:
@ -12,7 +12,7 @@ env:
jobs: jobs:
nightly: nightly:
name: Build and publish nightly APK to Firebase name: Build and publish nightly bundle to Firebase
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.repository == 'vector-im/element-x-android' }} if: ${{ github.repository == 'vector-im/element-x-android' }}
steps: steps:
@ -31,18 +31,21 @@ jobs:
sed 's/CHANGES\.md/CHANGES_NIGHTLY\.md/' towncrier.toml.bak > towncrier.toml sed 's/CHANGES\.md/CHANGES_NIGHTLY\.md/' towncrier.toml.bak > towncrier.toml
rm towncrier.toml.bak rm towncrier.toml.bak
yes n | towncrier build --version nightly yes n | towncrier build --version nightly
- name: Build and upload Nightly APK - name: Build and upload Nightly application
run: | run: |
./gradlew assembleNightly appDistributionUploadNightly $CI_GRADLE_ARG_PROPERTIES ./gradlew assembleNightly appDistributionUploadNightly $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_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }} ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }} ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }} ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}
FIREBASE_TOKEN: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_FIREBASE_TOKEN }} FIREBASE_TOKEN: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_FIREBASE_TOKEN }}
- 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: 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" 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"
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.6.1 uses: gradle/gradle-build-action@v2.7.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

@ -39,7 +39,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.6.1 uses: gradle/gradle-build-action@v2.7.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

@ -24,7 +24,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.6.1 uses: gradle/gradle-build-action@v2.7.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

40
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,40 @@
name: Create release App Bundle
on:
workflow_dispatch:
push:
branches: [ main ]
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
jobs:
release:
name: Create App Bundle
runs-on: ubuntu-latest
concurrency:
group: ${{ github.ref == 'refs/head/main' && format('build-release-main-{0}', github.sha) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v3
- name: Use JDK 17
uses: actions/setup-java@v3
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.7.0
- name: Create app bundle
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew bundleRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload bundle as artifact
uses: actions/upload-artifact@v3
with:
name: elementx-app-bundle-unsigned
path: |
app/build/outputs/bundle/release/app-release.aab

View file

@ -33,7 +33,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.6.1 uses: gradle/gradle-build-action@v2.7.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}

1
.gitignore vendored
View file

@ -38,6 +38,7 @@ captures/
# IntelliJ # IntelliJ
*.iml *.iml
.idea/.name .idea/.name
.idea/androidTestResultsUserPreferences.xml
.idea/assetWizardSettings.xml .idea/assetWizardSettings.xml
.idea/compiler.xml .idea/compiler.xml
.idea/deploymentTargetDropDown.xml .idea/deploymentTargetDropDown.xml

View file

@ -8,6 +8,7 @@
<w>measurables</w> <w>measurables</w>
<w>onboarding</w> <w>onboarding</w>
<w>placeables</w> <w>placeables</w>
<w>posthog</w>
<w>showkase</w> <w>showkase</w>
<w>snackbar</w> <w>snackbar</w>
<w>swipeable</w> <w>swipeable</w>

View file

@ -129,6 +129,8 @@ android {
// "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/nightly/app-universal-nightly.apk"
// artifactType = "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
releaseNotesFile = "CHANGES_NIGHTLY.md" releaseNotesFile = "CHANGES_NIGHTLY.md"
groups = "external-testers" groups = "external-testers"

View file

@ -21,8 +21,6 @@ import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.api.oidc.OidcIntentResolver import io.element.android.features.login.api.oidc.OidcIntentResolver
import io.element.android.libraries.deeplink.DeeplinkData import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser import io.element.android.libraries.deeplink.DeeplinkParser
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject

View file

@ -20,6 +20,7 @@ import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.Children import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
@ -161,13 +162,16 @@ class RoomLoadedFlowNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
// Rely on the View Lifecycle instead of the Node Lifecycle, // Rely on the View Lifecycle in addition to the Node Lifecycle,
// because this node enters 'onDestroy' before his children, so it can leads to // because this node enters 'onDestroy' before his children, so it can leads to
// using the room in a child node where it's already closed. // using the room in a child node where it's already closed.
DisposableEffect(Unit) { DisposableEffect(Unit) {
inputs.room.open() inputs.room.subscribeToSync()
onDispose { onDispose {
inputs.room.close() inputs.room.unsubscribeFromSync()
if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
inputs.room.destroy()
}
} }
} }
Children( Children(

View file

@ -16,7 +16,7 @@
package io.element.android.appnav package io.element.android.appnav
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow 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
@ -38,7 +38,7 @@ class RootPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createPresenter() val presenter = createPresenter()
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)
@ -54,7 +54,7 @@ class RootPresenterTest {
showError("Bad news", "Something bad happened") showError("Bad news", "Something bad happened")
} }
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)

View file

@ -16,7 +16,7 @@
package io.element.android.appnav.loggedin package io.element.android.appnav.loggedin
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow 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
@ -34,7 +34,7 @@ class LoggedInPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createPresenter() val presenter = createPresenter()
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()

View file

@ -90,6 +90,14 @@ allprojects {
apply { apply {
plugin("org.owasp.dependencycheck") plugin("org.owasp.dependencycheck")
} }
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
// Warnings are potential errors, so stop ignoring them
// This is disabled by default, but the CI will enforce this.
// You can override by passing `-PallWarningsAsErrors=true` in the command line
// Or add a line with "allWarningsAsErrors=true" in your ~/.gradle/gradle.properties file
kotlinOptions.allWarningsAsErrors = project.properties["allWarningsAsErrors"] == "true"
}
} }
// To run a sonar analysis: // To run a sonar analysis:

View file

@ -27,16 +27,21 @@ Place your API key in `local.properties` with the key
services.maptiler.apikey=abCd3fGhijK1mN0pQr5t services.maptiler.apikey=abCd3fGhijK1mN0pQr5t
``` ```
Optionally you can also place your custom MapTyler style ids for light and dark maps
in the `local.properties` with the keys `services.maptiler.lightMapId` and
`services.maptiler.darkMapId`. If you don't specify these, the default MapTiler "basic-v2"
styles will be used.
## Making releasable builds with MapTiler ## Making releasable builds with MapTiler
To insert the MapTiler API key when building an APK, set the To insert the MapTiler API key when building an APK, set the
`ELEMENT_ANDROID_MAPTILER_API_KEY` environment variable in your build `ELEMENT_ANDROID_MAPTILER_API_KEY` environment variable in your build
environment. environment.
If you've added custom styles also set the `ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID`
and `ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID` environment variables accordingly.
## Using other map sources or MapTiler styles ## Using other map sources or MapTiler styles
If you wish to use an alternative map provider, or custom MapTiler styles, If you wish to use an alternative map provider, you can provide your own implementations of
you can customise the functions in `TileServerStyleUriBuilder` and `StaticMapUrlBuilder` in
`features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt`. `features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/`.
We've kept this file small and self contained to minimise the chances of merge
collisions in forks.

View file

@ -52,6 +52,4 @@ dependencies {
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.analytics.test) testImplementation(projects.features.analytics.test)
testImplementation(projects.features.analytics.impl) testImplementation(projects.features.analytics.impl)
androidTestImplementation(libs.test.junitext)
} }

View file

@ -16,7 +16,7 @@
package io.element.android.features.analytics.impl package io.element.android.features.analytics.impl
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow 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
@ -35,7 +35,7 @@ class AnalyticsOptInPresenterTest {
aBuildMeta(), aBuildMeta(),
analyticsService analyticsService
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -53,7 +53,7 @@ class AnalyticsOptInPresenterTest {
aBuildMeta(), aBuildMeta(),
analyticsService analyticsService
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()

View file

@ -16,7 +16,7 @@
package io.element.android.features.analytics.impl.preferences package io.element.android.features.analytics.impl.preferences
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow 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
@ -33,7 +33,7 @@ class AnalyticsPreferencesPresenterTest {
FakeAnalyticsService(isEnabled = true, didAskUserConsent = true), FakeAnalyticsService(isEnabled = true, didAskUserConsent = true),
aBuildMeta() aBuildMeta()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)
@ -48,7 +48,7 @@ class AnalyticsPreferencesPresenterTest {
FakeAnalyticsService(isEnabled = false, didAskUserConsent = false), FakeAnalyticsService(isEnabled = false, didAskUserConsent = false),
aBuildMeta() aBuildMeta()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -62,7 +62,7 @@ class AnalyticsPreferencesPresenterTest {
FakeAnalyticsService(isEnabled = true, didAskUserConsent = true), FakeAnalyticsService(isEnabled = true, didAskUserConsent = true),
aBuildMeta() aBuildMeta()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)

View file

@ -33,7 +33,7 @@ class FakeAnalyticsService(
private val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent) private val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
val capturedEvents = mutableListOf<VectorAnalyticsEvent>() val capturedEvents = mutableListOf<VectorAnalyticsEvent>()
override fun getAvailableAnalyticsProviders(): List<AnalyticsProvider> = emptyList() override fun getAvailableAnalyticsProviders(): Set<AnalyticsProvider> = emptySet()
override fun getUserConsent(): Flow<Boolean> = isEnabledFlow override fun getUserConsent(): Flow<Boolean> = isEnabledFlow

View file

@ -66,7 +66,5 @@ dependencies {
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.usersearch.test)
androidTestImplementation(libs.test.junitext)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
} }

View file

@ -30,7 +30,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.createroom.impl.configureroom.RoomPrivacyItem import io.element.android.features.createroom.impl.configureroom.RoomPrivacyItem
import io.element.android.features.createroom.impl.configureroom.roomPrivacyItems import io.element.android.features.createroom.impl.configureroom.roomPrivacyItems
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark

View file

@ -16,7 +16,7 @@
package io.element.android.features.createroom.impl.addpeople package io.element.android.features.createroom.impl.addpeople
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow 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
@ -43,7 +43,7 @@ class AddPeoplePresenterTests {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
// TODO This doesn't actually test anything... // TODO This doesn't actually test anything...

View file

@ -17,7 +17,7 @@
package io.element.android.features.createroom.impl.configureroom package io.element.android.features.createroom.impl.configureroom
import android.net.Uri import android.net.Uri
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow 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
@ -93,7 +93,7 @@ class ConfigureRoomPresenterTests {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -108,7 +108,7 @@ class ConfigureRoomPresenterTests {
@Test @Test
fun `present - create room button is enabled only if the required fields are completed`() = runTest { fun `present - create room button is enabled only if the required fields are completed`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -133,7 +133,7 @@ class ConfigureRoomPresenterTests {
@Test @Test
fun `present - state is updated when fields are changed`() = runTest { fun `present - state is updated when fields are changed`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -203,7 +203,7 @@ class ConfigureRoomPresenterTests {
@Test @Test
fun `present - trigger create room action`() = runTest { fun `present - trigger create room action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -221,7 +221,7 @@ class ConfigureRoomPresenterTests {
@Test @Test
fun `present - record analytics when creating room`() = runTest { fun `present - record analytics when creating room`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -240,7 +240,7 @@ class ConfigureRoomPresenterTests {
@Test @Test
fun `present - trigger create room with upload error and retry`() = runTest { fun `present - trigger create room with upload error and retry`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)
@ -265,7 +265,7 @@ class ConfigureRoomPresenterTests {
@Test @Test
fun `present - trigger retry and cancel actions`() = runTest { fun `present - trigger retry and cancel actions`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()

View file

@ -16,7 +16,7 @@
package io.element.android.features.createroom.impl.root package io.element.android.features.createroom.impl.root
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow 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
@ -27,8 +27,6 @@ import io.element.android.features.createroom.impl.userlist.FakeUserListPresente
import io.element.android.features.createroom.impl.userlist.UserListDataStore import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.createroom.impl.userlist.aUserListState import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
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.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
@ -68,7 +66,7 @@ class CreateRoomRootPresenterTests {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -82,7 +80,7 @@ class CreateRoomRootPresenterTests {
@Test @Test
fun `present - trigger create DM action`() = runTest { fun `present - trigger create DM action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -102,7 +100,7 @@ class CreateRoomRootPresenterTests {
@Test @Test
fun `present - creating a DM records analytics event`() = runTest { fun `present - creating a DM records analytics event`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -123,7 +121,7 @@ class CreateRoomRootPresenterTests {
@Test @Test
fun `present - trigger retrieve DM action`() = runTest { fun `present - trigger retrieve DM action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -142,7 +140,7 @@ class CreateRoomRootPresenterTests {
@Test @Test
fun `present - trigger retry create DM action`() = runTest { fun `present - trigger retry create DM action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()

View file

@ -16,7 +16,7 @@
package io.element.android.features.createroom.impl.userlist package io.element.android.features.createroom.impl.userlist
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow 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
@ -41,7 +41,7 @@ class DefaultUserListPresenterTests {
userRepository, userRepository,
UserListDataStore(), UserListDataStore(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)
@ -62,7 +62,7 @@ class DefaultUserListPresenterTests {
userRepository, userRepository,
UserListDataStore(), UserListDataStore(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)
@ -83,7 +83,7 @@ class DefaultUserListPresenterTests {
userRepository, userRepository,
UserListDataStore(), UserListDataStore(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)
@ -119,7 +119,7 @@ class DefaultUserListPresenterTests {
userRepository, userRepository,
UserListDataStore(), UserListDataStore(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)
@ -158,7 +158,7 @@ class DefaultUserListPresenterTests {
userRepository, userRepository,
UserListDataStore(), UserListDataStore(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)
@ -183,7 +183,7 @@ class DefaultUserListPresenterTests {
userRepository, userRepository,
UserListDataStore(), UserListDataStore(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_welcome_bullet_1">"Anrufe, Standortfreigabe, Suche und mehr werden später in diesem Jahr hinzugefügt."</string>
<string name="screen_welcome_bullet_2">"Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein."</string>
<string name="screen_welcome_bullet_3">"Wir würden uns freuen, wenn du uns über die Einstellungsseite deine Meinung mitteilst."</string>
<string name="screen_welcome_button">"Los geht\'s!"</string>
<string name="screen_welcome_subtitle">"Folgendes musst du wissen:"</string>
<string name="screen_welcome_title">"Willkommen bei %1$s!"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_welcome_bullet_2">"Lhistorique des messages pour les salons chiffrés ne sera pas disponible dans cette mise à jour."</string>
<string name="screen_welcome_bullet_3">"Nous serions ravis davoir votre avis, nhésitez pas à nous le partager via la page des paramètres."</string>
<string name="screen_welcome_button">"Cest parti !"</string>
<string name="screen_welcome_subtitle">"Voici ce quil faut savoir :"</string>
<string name="screen_welcome_title">"Bienvenue sur %1$s !"</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_welcome_bullet_1">"Hovory, zdieľanie polohy, vyhľadávanie a ďalšie funkcie pribudnú neskôr v tomto roku."</string>
<string name="screen_welcome_bullet_2">"História správ pre zašifrované miestnosti nebude v tejto aktualizácii k dispozícii."</string>
<string name="screen_welcome_bullet_3">"Radi by sme od vás počuli, dajte nám vedieť, čo si myslíte, prostredníctvom stránky nastavení."</string>
<string name="screen_welcome_button">"Poďme na to!"</string>
<string name="screen_welcome_subtitle">"Tu je to, čo potrebujete vedieť:"</string>
<string name="screen_welcome_title">"Vitajte v %1$s!"</string>
</resources>

View file

@ -1,6 +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="screen_welcome_bullet_1">"Calls, location sharing, search and more will be added later this year."</string> <string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string>
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms wont be available in this update."</string> <string name="screen_welcome_bullet_2">"Message history for encrypted rooms wont be available in this update."</string>
<string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string> <string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string>
<string name="screen_welcome_button">"Let\'s go!"</string> <string name="screen_welcome_button">"Let\'s go!"</string>

View file

@ -25,7 +25,6 @@ import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test

View file

@ -16,7 +16,7 @@
package io.element.android.features.invitelist.impl package io.element.android.features.invitelist.impl
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
@ -55,7 +55,7 @@ class InviteListPresenterTests {
val presenter = createPresenter( val presenter = createPresenter(
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -76,7 +76,7 @@ class InviteListPresenterTests {
val presenter = createPresenter( val presenter = createPresenter(
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val withInviteState = awaitItem() val withInviteState = awaitItem()
@ -102,7 +102,7 @@ class InviteListPresenterTests {
val presenter = createPresenter( val presenter = createPresenter(
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val withInviteState = awaitItem() val withInviteState = awaitItem()
@ -131,7 +131,7 @@ class InviteListPresenterTests {
FakeAnalyticsService(), FakeAnalyticsService(),
FakeNotificationDrawerManager() FakeNotificationDrawerManager()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val originalState = awaitItem() val originalState = awaitItem()
@ -152,7 +152,7 @@ class InviteListPresenterTests {
val presenter = createPresenter( val presenter = createPresenter(
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val originalState = awaitItem() val originalState = awaitItem()
@ -173,7 +173,7 @@ class InviteListPresenterTests {
val presenter = createPresenter( val presenter = createPresenter(
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val originalState = awaitItem() val originalState = awaitItem()
@ -199,7 +199,7 @@ class InviteListPresenterTests {
val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager) val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager)
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val originalState = awaitItem() val originalState = awaitItem()
@ -227,7 +227,7 @@ class InviteListPresenterTests {
room.givenLeaveRoomError(ex) room.givenLeaveRoomError(ex)
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val originalState = awaitItem() val originalState = awaitItem()
@ -257,7 +257,7 @@ class InviteListPresenterTests {
room.givenLeaveRoomError(ex) room.givenLeaveRoomError(ex)
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val originalState = awaitItem() val originalState = awaitItem()
@ -288,7 +288,7 @@ class InviteListPresenterTests {
val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager) val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager)
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val originalState = awaitItem() val originalState = awaitItem()
@ -313,7 +313,7 @@ class InviteListPresenterTests {
room.givenJoinRoomResult(Result.failure(ex)) room.givenJoinRoomResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val originalState = awaitItem() val originalState = awaitItem()
@ -335,7 +335,7 @@ class InviteListPresenterTests {
room.givenJoinRoomResult(Result.failure(ex)) room.givenJoinRoomResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val originalState = awaitItem() val originalState = awaitItem()
@ -362,7 +362,7 @@ class InviteListPresenterTests {
FakeAnalyticsService(), FakeAnalyticsService(),
FakeNotificationDrawerManager() FakeNotificationDrawerManager()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
awaitItem() awaitItem()
@ -400,7 +400,7 @@ class InviteListPresenterTests {
FakeAnalyticsService(), FakeAnalyticsService(),
FakeNotificationDrawerManager() FakeNotificationDrawerManager()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
awaitItem() awaitItem()

View file

@ -16,7 +16,7 @@
package io.element.android.features.leaveroom.impl package io.element.android.features.leaveroom.impl
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow 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
@ -40,7 +40,7 @@ class LeaveRoomPresenterImplTest {
@Test @Test
fun `present - initial state hides all dialogs`() = runTest { fun `present - initial state hides all dialogs`() = runTest {
val presenter = createPresenter() val presenter = createPresenter()
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -60,7 +60,7 @@ class LeaveRoomPresenterImplTest {
) )
} }
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -80,7 +80,7 @@ class LeaveRoomPresenterImplTest {
) )
} }
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -100,7 +100,7 @@ class LeaveRoomPresenterImplTest {
) )
} }
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -122,7 +122,7 @@ class LeaveRoomPresenterImplTest {
}, },
roomMembershipObserver = roomMembershipObserver roomMembershipObserver = roomMembershipObserver
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -145,7 +145,7 @@ class LeaveRoomPresenterImplTest {
) )
} }
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -167,7 +167,7 @@ class LeaveRoomPresenterImplTest {
) )
} }
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -191,7 +191,7 @@ class LeaveRoomPresenterImplTest {
) )
} }
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()

View file

@ -22,12 +22,12 @@ plugins {
id("kotlin-parcelize") id("kotlin-parcelize")
} }
fun readLocalProperty(name: String) = Properties().apply { fun readLocalProperty(name: String): String? = Properties().apply {
try { try {
load(rootProject.file("local.properties").reader()) load(rootProject.file("local.properties").reader())
} catch (ignored: java.io.IOException) { } catch (ignored: java.io.IOException) {
} }
}[name] }.getProperty(name)
android { android {
namespace = "io.element.android.features.location.api" namespace = "io.element.android.features.location.api"
@ -37,9 +37,23 @@ android {
type = "string", type = "string",
name = "maptiler_api_key", name = "maptiler_api_key",
value = System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY") value = System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY")
?: readLocalProperty("services.maptiler.apikey") as? String ?: readLocalProperty("services.maptiler.apikey")
?: "" ?: ""
) )
resValue(
type = "string",
name = "maptiler_light_map_id",
value = System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID")
?: readLocalProperty("services.maptiler.lightMapId")
?: "basic-v2" // fall back to maptiler's default light map.
)
resValue(
type = "string",
name = "maptiler_dark_map_id",
value = System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID")
?: readLocalProperty("services.maptiler.darkMapId")
?: "basic-v2-dark" // fall back to maptiler's default dark map.
)
} }
} }

View file

@ -29,16 +29,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImagePainter import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest import coil.request.ImageRequest
import io.element.android.features.location.api.internal.StaticMapPlaceholder import io.element.android.features.location.api.internal.StaticMapPlaceholder
import io.element.android.features.location.api.internal.StaticMapUrlBuilder
import io.element.android.features.location.api.internal.centerBottomEdge import io.element.android.features.location.api.internal.centerBottomEdge
import io.element.android.features.location.api.internal.staticMapUrl
import io.element.android.libraries.designsystem.preview.DayNightPreviews import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import timber.log.Timber import timber.log.Timber
@ -65,23 +65,22 @@ fun StaticMapView(
) { ) {
val context = LocalContext.current val context = LocalContext.current
var retryHash by remember { mutableStateOf(0) } var retryHash by remember { mutableStateOf(0) }
val builder = remember { StaticMapUrlBuilder(context) }
val painter = rememberAsyncImagePainter( val painter = rememberAsyncImagePainter(
model = if (constraints.isZero) { model = if (constraints.isZero) {
// Avoid building a URL if any of the size constraints is zero (else it will thrown an exception). // Avoid building a URL if any of the size constraints is zero (else it will thrown an exception).
null null
} else { } else {
ImageRequest.Builder(LocalContext.current) ImageRequest.Builder(context)
.data( .data(
staticMapUrl( builder.build(
context = context,
lat = lat, lat = lat,
lon = lon, lon = lon,
zoom = zoom, zoom = zoom,
darkMode = darkMode, darkMode = darkMode,
// Size the map based on DP rather than pixels, as otherwise the features and attribution width = constraints.maxWidth,
// end up being illegibly tiny on high density displays. height = constraints.maxHeight,
width = constraints.maxWidth.toDp().value.toInt(), density = LocalDensity.current.density,
height = constraints.maxHeight.toDp().value.toInt(),
) )
) )
.size(width = constraints.maxWidth, height = constraints.maxHeight) .size(width = constraints.maxWidth, height = constraints.maxHeight)

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.api.internal
import android.content.Context
import io.element.android.features.location.api.R
internal const val MAPTILER_BASE_URL = "https://api.maptiler.com/maps"
internal fun Context.mapId(darkMode: Boolean) = when (darkMode) {
true -> getString(R.string.maptiler_dark_map_id)
false -> getString(R.string.maptiler_light_map_id)
}
internal val Context.apiKey: String
get() = getString(R.string.maptiler_api_key)

View file

@ -0,0 +1,93 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.api.internal
import android.content.Context
import kotlin.math.roundToInt
/**
* Builds an URL for MapTiler's Static Maps API.
*
* https://docs.maptiler.com/cloud/api/static-maps/
*/
internal class MapTilerStaticMapUrlBuilder(
private val apiKey: String,
private val lightMapId: String,
private val darkMapId: String,
) : StaticMapUrlBuilder {
constructor(context: Context) : this(
apiKey = context.apiKey,
lightMapId = context.mapId(darkMode = false),
darkMapId = context.mapId(darkMode = true),
)
override fun build(
lat: Double,
lon: Double,
zoom: Double,
darkMode: Boolean,
width: Int,
height: Int,
density: Float
): String {
val mapId = if (darkMode) darkMapId else lightMapId
val finalZoom = zoom.coerceIn(zoomRange)
// Request @2x density for xhdpi and above (xhdpi == 320dpi == 2x density).
val is2x = density >= 2
// Scale requested width/height according to the reported display density.
val (finalWidth, finalHeight) = coerceWidthAndHeight(
width = (width / density).roundToInt(),
height = (height / density).roundToInt(),
is2x = is2x,
)
val scale = if (is2x) "@2x" else ""
// Since Maptiler doesn't support arbitrary dpi scaling, we stick to 2x sized
// images even on displays with density higher than 2x, thereby yielding an
// image smaller than the available space in pixels.
// The resulting image will have to be scaled to fit the available space in order
// to keep the perceived content size constant at the expense of sharpness.
return "$MAPTILER_BASE_URL/${mapId}/static/${lon},${lat},${finalZoom}/${finalWidth}x${finalHeight}${scale}.webp?key=${apiKey}&attribution=bottomleft"
}
}
private fun coerceWidthAndHeight(width: Int, height: Int, is2x: Boolean): Pair<Int, Int> {
if (width <= 0 || height <= 0) {
// This effectively yields an URL which asks for a 0x0 image which will result in an HTTP error,
// but it's better than e.g. asking for a 1x1 image which would be unreadable and increase usage costs.
return 0 to 0
}
val aspectRatio = width.toDouble() / height.toDouble()
val range = if (is2x) widthHeightRange2x else widthHeightRange
return if (width >= height) {
width.coerceIn(range).let { coercedWidth ->
coercedWidth to (coercedWidth / aspectRatio).roundToInt()
}
} else {
height.coerceIn(range).let { coercedHeight ->
(coercedHeight * aspectRatio).roundToInt() to coercedHeight
}
}
}
private val widthHeightRange = 1..2048 // API will error if outside 1-2048 range @1x.
private val widthHeightRange2x = 1..1024 // API will error if outside 1-1024 range @2x.
private val zoomRange = 0.0..22.0 // API will error if outside 0-22 range.

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:JvmName("TileServerStyleUriBuilderKt")
package io.element.android.features.location.api.internal
import android.content.Context
internal class MapTilerTileServerStyleUriBuilder(
private val apiKey: String,
private val lightMapId: String,
private val darkMapId: String,
) : TileServerStyleUriBuilder {
constructor(context: Context) : this(
apiKey = context.apiKey,
lightMapId = context.mapId(darkMode = false),
darkMapId = context.mapId(darkMode = true),
)
override fun build(darkMode: Boolean): String {
val mapId = if (darkMode) darkMapId else lightMapId
return "${MAPTILER_BASE_URL}/${mapId}/style.json?key=${apiKey}"
}
}

View file

@ -1,74 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.api.internal
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import io.element.android.features.location.api.R
import io.element.android.libraries.theme.ElementTheme
/**
* Provides the URL to an image that contains a statically-generated map of the given location.
*/
fun staticMapUrl(
context: Context,
lat: Double,
lon: Double,
zoom: Double,
width: Int,
height: Int,
darkMode: Boolean,
): String {
return "${baseUrl(darkMode)}/static/${lon},${lat},${zoom}/${width}x${height}@2x.webp?key=${context.apiKey}&attribution=bottomleft"
}
/**
* Utility function to remember the tile server URL based on the current theme.
*/
@Composable
fun rememberTileStyleUrl(): String {
val context = LocalContext.current
val darkMode = !ElementTheme.isLightTheme
return remember(darkMode) {
tileStyleUrl(
context = context,
darkMode = darkMode
)
}
}
/**
* Provides the URL to a MapLibre style document, used for rendering dynamic maps.
*/
private fun tileStyleUrl(
context: Context,
darkMode: Boolean,
): String {
return "${baseUrl(darkMode)}/style.json?key=${context.apiKey}"
}
private fun baseUrl(darkMode: Boolean) =
"https://api.maptiler.com/maps/" +
if (darkMode)
"dea61faf-292b-4774-9660-58fcef89a7f3"
else
"9bc819c8-e627-474a-a348-ec144fe3d810"
private val Context.apiKey: String
get() = getString(R.string.maptiler_api_key)

View file

@ -29,7 +29,7 @@ fun Modifier.centerBottomEdge(scope: BoxScope): Modifier = with(scope) {
Modifier.align { size, space, _ -> Modifier.align { size, space, _ ->
IntOffset( IntOffset(
x = (space.width - size.width) / 2, x = (space.width - size.width) / 2,
y = (space.height / 2) - size.height, y = space.height / 2 - size.height,
) )
} }
) )

View file

@ -0,0 +1,36 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.api.internal
import android.content.Context
/**
* Builds an URL for a 3rd party service provider static maps API.
*/
interface StaticMapUrlBuilder {
fun build(
lat: Double,
lon: Double,
zoom: Double,
darkMode: Boolean,
width: Int,
height: Int,
density: Float,
): String
}
fun StaticMapUrlBuilder(context: Context): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder(context = context)

View file

@ -0,0 +1,50 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.api.internal
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import io.element.android.libraries.theme.ElementTheme
/**
* Builds a style URI for a MapLibre compatible tile server.
*
* Used for rendering dynamic maps.
*/
interface TileServerStyleUriBuilder {
fun build(
darkMode: Boolean,
): String
}
fun TileServerStyleUriBuilder(context: Context): TileServerStyleUriBuilder = MapTilerTileServerStyleUriBuilder(context = context)
/**
* Provides and remembers a style URI for a MapLibre compatible tile server.
*
* Used for rendering dynamic maps.
*/
@Composable
fun rememberTileStyleUrl(): String {
val context = LocalContext.current
val darkMode = !ElementTheme.isLightTheme
return remember(darkMode) {
TileServerStyleUriBuilder(context).build(darkMode)
}
}

View file

@ -0,0 +1,191 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.api.internal
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class MapTilerStaticMapUrlBuilderTest {
private val builder = MapTilerStaticMapUrlBuilder(
apiKey = "anApiKey",
lightMapId = "aLightMapId",
darkMapId = "aDarkMapId",
)
@Test
fun `static map 1x density`() {
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 800,
height = 600,
density = 1f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
}
@Test
fun `static map 1,5x density`() {
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 1200,
height = 900,
density = 1.5f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
}
@Test
fun `static map 2x density`() {
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 1600,
height = 1200,
density = 2f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
}
@Test
fun `static map 3x density`() {
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 2400,
height = 1800,
density = 3f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
}
@Test
fun `too big image is coerced keeping aspect ratio`() {
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 4096,
height = 2048,
density = 1f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/2048x1024.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 2048,
height = 4096,
density = 1f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/1024x2048.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 4096,
height = 2048,
density = 2f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/1024x512@2x.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 2048,
height = 4096,
density = 2f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/512x1024@2x.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = Int.MAX_VALUE,
height = Int.MAX_VALUE,
density = 2f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/1024x1024@2x.webp?key=anApiKey&attribution=bottomleft")
}
@Test
fun `too small image is coerced to 0x0`() {
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 0,
height = 0,
density = 1f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 0,
height = 0,
density = 2f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/0x0@2x.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = Int.MIN_VALUE,
height = Int.MIN_VALUE,
density = 1f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.api.internal
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class MapTilerTileServerStyleUriBuilderTest {
private val builder = MapTilerTileServerStyleUriBuilder(
apiKey = "anApiKey",
lightMapId = "aLightMapId",
darkMapId = "aDarkMapId",
)
@Test
fun `light map uri`() {
assertThat(
builder.build(darkMode = false)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/style.json?key=anApiKey")
}
@Test
fun `dark map uri`() {
assertThat(
builder.build(darkMode = true)
).isEqualTo("https://api.maptiler.com/maps/aDarkMapId/style.json?key=anApiKey")
}
}

View file

@ -45,7 +45,6 @@ dependencies {
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(libs.dagger) implementation(libs.dagger)
implementation(projects.anvilannotations) implementation(projects.anvilannotations)
implementation(projects.services.toolbox.api)
anvil(projects.anvilcodegen) anvil(projects.anvilcodegen)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)

View file

@ -20,6 +20,7 @@ import android.Manifest
import android.view.Gravity import android.view.Gravity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import com.mapbox.mapboxsdk.camera.CameraPosition import com.mapbox.mapboxsdk.camera.CameraPosition
import com.mapbox.mapboxsdk.geometry.LatLng import com.mapbox.mapboxsdk.geometry.LatLng
import io.element.android.libraries.maplibre.compose.MapLocationSettings import io.element.android.libraries.maplibre.compose.MapLocationSettings
@ -53,6 +54,13 @@ object MapDefaults {
val locationSettings: MapLocationSettings val locationSettings: MapLocationSettings
get() = MapLocationSettings( get() = MapLocationSettings(
locationEnabled = false, locationEnabled = false,
backgroundTintColor = Color.White,
foregroundTintColor = Color.Black,
backgroundStaleTintColor = Color.White,
foregroundStaleTintColor = Color.Black,
accuracyColor = Color.Black,
pulseEnabled = true,
pulseColor = Color.Black,
) )
val centerCameraPosition = CameraPosition.Builder() val centerCameraPosition = CameraPosition.Builder()

View file

@ -36,12 +36,7 @@ import io.element.android.libraries.core.meta.BuildMeta
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.location.AssetType import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import javax.inject.Inject import javax.inject.Inject
class SendLocationPresenter @Inject constructor( class SendLocationPresenter @Inject constructor(
@ -50,7 +45,6 @@ class SendLocationPresenter @Inject constructor(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val messageComposerContext: MessageComposerContext, private val messageComposerContext: MessageComposerContext,
private val locationActions: LocationActions, private val locationActions: LocationActions,
private val systemClock: SystemClock,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
) : Presenter<SendLocationState> { ) : Presenter<SendLocationState> {
@ -115,7 +109,7 @@ class SendLocationPresenter @Inject constructor(
SendLocationState.Mode.PinLocation -> { SendLocationState.Mode.PinLocation -> {
val geoUri = event.cameraPosition.toGeoUri() val geoUri = event.cameraPosition.toGeoUri()
room.sendLocation( room.sendLocation(
body = generateBody(geoUri, systemClock.epochMillis()), body = generateBody(geoUri),
geoUri = geoUri, geoUri = geoUri,
description = null, description = null,
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
@ -134,7 +128,7 @@ class SendLocationPresenter @Inject constructor(
SendLocationState.Mode.SenderLocation -> { SendLocationState.Mode.SenderLocation -> {
val geoUri = event.toGeoUri() val geoUri = event.toGeoUri()
room.sendLocation( room.sendLocation(
body = generateBody(geoUri, systemClock.epochMillis()), body = generateBody(geoUri),
geoUri = geoUri, geoUri = geoUri,
description = null, description = null,
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
@ -158,7 +152,4 @@ private fun SendLocationEvents.SendLocation.toGeoUri(): String = location?.toGeo
private fun SendLocationEvents.SendLocation.CameraPosition.toGeoUri(): String = "geo:$lat,$lon" private fun SendLocationEvents.SendLocation.CameraPosition.toGeoUri(): String = "geo:$lat,$lon"
private fun generateBody(uri: String, epochMillis: Long): String { private fun generateBody(uri: String): String = "Location was shared at $uri"
val timestamp = ZonedDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT)
return "Location was shared at $uri as of $timestamp"
}

View file

@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.LocationSearching import androidx.compose.material.icons.filled.LocationSearching
import androidx.compose.material.icons.filled.MyLocation import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -49,6 +48,7 @@ import io.element.android.features.location.api.Location
import io.element.android.features.location.api.internal.centerBottomEdge import io.element.android.features.location.api.internal.centerBottomEdge
import io.element.android.features.location.api.internal.rememberTileStyleUrl import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.MapDefaults import io.element.android.features.location.impl.MapDefaults
import io.element.android.features.location.impl.R
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.DayNightPreviews import io.element.android.libraries.designsystem.preview.DayNightPreviews
@ -156,7 +156,11 @@ fun SendLocationView(
navigateUp() navigateUp()
}, },
leadingContent = { leadingContent = {
Icon(Icons.Default.LocationOn, null) Icon(
resourceId = R.drawable.pin_small,
contentDescription = null,
tint = Color.Unspecified,
)
}, },
) )
Spacer(modifier = Modifier.height(16.dp + navBarPadding)) Spacer(modifier = Modifier.height(16.dp + navBarPadding))

View file

@ -50,6 +50,13 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
isTrackMyLocation = false, isTrackMyLocation = false,
eventSink = {}, eventSink = {},
), ),
ShowLocationState(
Location(1.23, 2.34, 4f),
description = "For some reason I decided to to write a small essay that wraps at just two lines!",
hasLocationPermission = false,
isTrackMyLocation = false,
eventSink = {},
),
ShowLocationState( ShowLocationState(
Location(1.23, 2.34, 4f), Location(1.23, 2.34, 4f),
description = "For some reason I decided to write a small essay in the location description. " + description = "For some reason I decided to write a small essay in the location description. " +

View file

@ -40,7 +40,6 @@ import com.mapbox.mapboxsdk.camera.CameraPosition
import com.mapbox.mapboxsdk.geometry.LatLng import com.mapbox.mapboxsdk.geometry.LatLng
import io.element.android.features.location.api.internal.rememberTileStyleUrl import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.MapDefaults import io.element.android.features.location.impl.MapDefaults
import io.element.android.features.location.impl.send.SendLocationState
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight

View file

@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="28dp"
android:viewportWidth="26"
android:viewportHeight="28">
<path
android:pathData="M12.962,28L9.819,24.889L16.105,24.889L12.962,28Z"
android:fillColor="#EBEEF2"/>
<path
android:pathData="M12.963,12.963m-12.963,0a12.963,12.963 0,1 1,25.926 0a12.963,12.963 0,1 1,-25.926 0"
android:fillColor="#EBEEF2"/>
<group>
<clip-path
android:pathData="M6.74,6.74h12.444v12.444h-12.444z"/>
<path
android:pathData="M12.962,6.74C10.554,6.74 8.606,8.741 8.606,11.215C8.606,13.88 11.357,17.555 12.489,18.955C12.738,19.262 13.192,19.262 13.441,18.955C14.567,17.555 17.318,13.88 17.318,11.215C17.318,8.741 15.37,6.74 12.962,6.74ZM12.962,12.813C12.103,12.813 11.406,12.097 11.406,11.215C11.406,10.333 12.103,9.617 12.962,9.617C13.821,9.617 14.518,10.333 14.518,11.215C14.518,12.097 13.821,12.813 12.962,12.813Z"
android:fillColor="#101317"/>
</group>
</vector>

View file

@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="28dp"
android:viewportWidth="26"
android:viewportHeight="28">
<path
android:pathData="M12.962,28L9.819,24.889L16.105,24.889L12.962,28Z"
android:fillColor="#1B1D22"/>
<path
android:pathData="M12.963,12.963m-12.963,0a12.963,12.963 0,1 1,25.926 0a12.963,12.963 0,1 1,-25.926 0"
android:fillColor="#1B1D22"/>
<group>
<clip-path
android:pathData="M6.74,6.741h12.444v12.444h-12.444z"/>
<path
android:pathData="M12.962,6.741C10.554,6.741 8.606,8.741 8.606,11.215C8.606,13.88 11.357,17.555 12.489,18.955C12.738,19.262 13.192,19.262 13.441,18.955C14.567,17.555 17.318,13.88 17.318,11.215C17.318,8.741 15.37,6.741 12.962,6.741ZM12.962,12.813C12.103,12.813 11.406,12.097 11.406,11.215C11.406,10.333 12.103,9.617 12.962,9.617C13.821,9.617 14.518,10.333 14.518,11.215C14.518,12.097 13.821,12.813 12.962,12.813Z"
android:fillColor="#ffffff"/>
</group>
</vector>

View file

@ -16,7 +16,7 @@
package io.element.android.features.location.impl.send package io.element.android.features.location.impl.send
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
@ -34,7 +34,6 @@ import io.element.android.libraries.matrix.test.core.aBuildMeta
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.SendLocationInvocation import io.element.android.libraries.matrix.test.room.SendLocationInvocation
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -46,7 +45,6 @@ class SendLocationPresenterTest {
private val fakeAnalyticsService = FakeAnalyticsService() private val fakeAnalyticsService = FakeAnalyticsService()
private val messageComposerContextFake = MessageComposerContextFake() private val messageComposerContextFake = MessageComposerContextFake()
private val fakeLocationActions = FakeLocationActions() private val fakeLocationActions = FakeLocationActions()
private val fakeSystemClock = SystemClock { 0L }
private val fakeBuildMeta = aBuildMeta(applicationName = "app name") private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
private val sendLocationPresenter: SendLocationPresenter = SendLocationPresenter( private val sendLocationPresenter: SendLocationPresenter = SendLocationPresenter(
permissionsPresenterFactory = object : PermissionsPresenter.Factory { permissionsPresenterFactory = object : PermissionsPresenter.Factory {
@ -56,7 +54,6 @@ class SendLocationPresenterTest {
analyticsService = fakeAnalyticsService, analyticsService = fakeAnalyticsService,
messageComposerContext = messageComposerContextFake, messageComposerContext = messageComposerContextFake,
locationActions = fakeLocationActions, locationActions = fakeLocationActions,
systemClock = fakeSystemClock,
buildMeta = fakeBuildMeta, buildMeta = fakeBuildMeta,
) )
@ -69,7 +66,7 @@ class SendLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present() sendLocationPresenter.present()
}.test { }.test {
@ -96,7 +93,7 @@ class SendLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present() sendLocationPresenter.present()
}.test { }.test {
@ -123,7 +120,7 @@ class SendLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present() sendLocationPresenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -149,7 +146,7 @@ class SendLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present() sendLocationPresenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -175,7 +172,7 @@ class SendLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present() sendLocationPresenter.present()
}.test { }.test {
// Skip initial state // Skip initial state
@ -206,7 +203,7 @@ class SendLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present() sendLocationPresenter.present()
}.test { }.test {
// Skip initial state // Skip initial state
@ -234,7 +231,7 @@ class SendLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present() sendLocationPresenter.present()
}.test { }.test {
// Skip initial state // Skip initial state
@ -265,7 +262,7 @@ class SendLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present() sendLocationPresenter.present()
}.test { }.test {
// Skip initial state // Skip initial state
@ -292,7 +289,7 @@ class SendLocationPresenterTest {
Truth.assertThat(fakeMatrixRoom.sentLocations.size).isEqualTo(1) Truth.assertThat(fakeMatrixRoom.sentLocations.size).isEqualTo(1)
Truth.assertThat(fakeMatrixRoom.sentLocations.last()).isEqualTo( Truth.assertThat(fakeMatrixRoom.sentLocations.last()).isEqualTo(
SendLocationInvocation( SendLocationInvocation(
body = "Location was shared at geo:3.0,4.0;u=5.0 as of 1970-01-01T00:00:00Z", body = "Location was shared at geo:3.0,4.0;u=5.0",
geoUri = "geo:3.0,4.0;u=5.0", geoUri = "geo:3.0,4.0;u=5.0",
description = null, description = null,
zoomLevel = 15, zoomLevel = 15,
@ -322,7 +319,7 @@ class SendLocationPresenterTest {
) )
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present() sendLocationPresenter.present()
}.test { }.test {
// Skip initial state // Skip initial state
@ -349,7 +346,7 @@ class SendLocationPresenterTest {
Truth.assertThat(fakeMatrixRoom.sentLocations.size).isEqualTo(1) Truth.assertThat(fakeMatrixRoom.sentLocations.size).isEqualTo(1)
Truth.assertThat(fakeMatrixRoom.sentLocations.last()).isEqualTo( Truth.assertThat(fakeMatrixRoom.sentLocations.last()).isEqualTo(
SendLocationInvocation( SendLocationInvocation(
body = "Location was shared at geo:0.0,1.0 as of 1970-01-01T00:00:00Z", body = "Location was shared at geo:0.0,1.0",
geoUri = "geo:0.0,1.0", geoUri = "geo:0.0,1.0",
description = null, description = null,
zoomLevel = 15, zoomLevel = 15,
@ -384,7 +381,7 @@ class SendLocationPresenterTest {
) )
} }
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present() sendLocationPresenter.present()
}.test { }.test {
// Skip initial state // Skip initial state
@ -431,7 +428,7 @@ class SendLocationPresenterTest {
) )
} }
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present() sendLocationPresenter.present()
}.test { }.test {
// Skip initial state // Skip initial state
@ -451,7 +448,7 @@ class SendLocationPresenterTest {
@Test @Test
fun `application name is in state`() = runTest { fun `application name is in state`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present() sendLocationPresenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()

View file

@ -16,7 +16,7 @@
package io.element.android.features.location.impl.show package io.element.android.features.location.impl.show
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
@ -44,7 +44,7 @@ class ShowLocationPresenterTest {
@Test @Test
fun `emits initial state with no location permission`() = runTest { fun `emits initial state with no location permission`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -59,7 +59,7 @@ class ShowLocationPresenterTest {
fun `emits initial state with location permission`() = runTest { fun `emits initial state with location permission`() = runTest {
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted)) permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted))
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -74,7 +74,7 @@ class ShowLocationPresenterTest {
fun `emits initial state with partial location permission`() = runTest { fun `emits initial state with partial location permission`() = runTest {
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.SomeGranted)) permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.SomeGranted))
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -87,7 +87,7 @@ class ShowLocationPresenterTest {
@Test @Test
fun `uses action to share location`() = runTest { fun `uses action to share location`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -102,7 +102,7 @@ class ShowLocationPresenterTest {
fun `centers on user location`() = runTest { fun `centers on user location`() = runTest {
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted)) permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted))
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()

View file

@ -61,6 +61,4 @@ dependencies {
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
androidTestImplementation(libs.test.junitext)
} }

View file

@ -30,7 +30,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp

View file

@ -30,7 +30,8 @@ data class LoginPasswordState(
) { ) {
val submitEnabled: Boolean val submitEnabled: Boolean
get() = loginAction !is Async.Failure && get() = loginAction !is Async.Failure &&
((formState.login.isNotEmpty() && formState.password.isNotEmpty())) formState.login.isNotEmpty() &&
formState.password.isNotEmpty()
} }
@Parcelize @Parcelize

View file

@ -6,9 +6,9 @@
<string name="screen_account_provider_form_notice">"Zadajte hľadaný výraz alebo adresu domény."</string> <string name="screen_account_provider_form_notice">"Zadajte hľadaný výraz alebo adresu domény."</string>
<string name="screen_account_provider_form_subtitle">"Vyhľadať spoločnosť, komunitu alebo súkromný server."</string> <string name="screen_account_provider_form_subtitle">"Vyhľadať spoločnosť, komunitu alebo súkromný server."</string>
<string name="screen_account_provider_form_title">"Nájsť poskytovateľa účtu"</string> <string name="screen_account_provider_form_title">"Nájsť poskytovateľa účtu"</string>
<string name="screen_account_provider_signin_subtitle">"Tu budú žiť vaše konverzácie - podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov."</string> <string name="screen_account_provider_signin_subtitle">"Tu budú žiť vaše konverzácie podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov."</string>
<string name="screen_account_provider_signin_title">"Chystáte sa prihlásiť do %s"</string> <string name="screen_account_provider_signin_title">"Chystáte sa prihlásiť do %s"</string>
<string name="screen_account_provider_signup_subtitle">"Tu budú žiť vaše konverzácie - podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov."</string> <string name="screen_account_provider_signup_subtitle">"Tu budú žiť vaše konverzácie podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov."</string>
<string name="screen_account_provider_signup_title">"Chystáte sa vytvoriť účet na %s"</string> <string name="screen_account_provider_signup_title">"Chystáte sa vytvoriť účet na %s"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu."</string> <string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu."</string>
<string name="screen_change_account_provider_other">"Iný"</string> <string name="screen_change_account_provider_other">"Iný"</string>

View file

@ -16,7 +16,7 @@
package io.element.android.features.login.impl.changeserver package io.element.android.features.login.impl.changeserver
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow 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
@ -36,7 +36,7 @@ class ChangeServerPresenterTest {
FakeAuthenticationService(), FakeAuthenticationService(),
AccountProviderDataSource() AccountProviderDataSource()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -51,7 +51,7 @@ class ChangeServerPresenterTest {
authenticationService, authenticationService,
AccountProviderDataSource() AccountProviderDataSource()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -72,7 +72,7 @@ class ChangeServerPresenterTest {
authenticationService, authenticationService,
AccountProviderDataSource() AccountProviderDataSource()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()

View file

@ -18,7 +18,7 @@
package io.element.android.features.login.impl.oidc.webview package io.element.android.features.login.impl.oidc.webview
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow 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
@ -38,7 +38,7 @@ class OidcPresenterTest {
A_OIDC_DATA, A_OIDC_DATA,
FakeAuthenticationService(), FakeAuthenticationService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -53,7 +53,7 @@ class OidcPresenterTest {
A_OIDC_DATA, A_OIDC_DATA,
FakeAuthenticationService(), FakeAuthenticationService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -73,7 +73,7 @@ class OidcPresenterTest {
authenticationService, authenticationService,
) )
authenticationService.givenOidcCancelError(A_THROWABLE) authenticationService.givenOidcCancelError(A_THROWABLE)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -92,7 +92,7 @@ class OidcPresenterTest {
A_OIDC_DATA, A_OIDC_DATA,
FakeAuthenticationService(), FakeAuthenticationService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -110,7 +110,7 @@ class OidcPresenterTest {
A_OIDC_DATA, A_OIDC_DATA,
FakeAuthenticationService(), FakeAuthenticationService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -129,7 +129,7 @@ class OidcPresenterTest {
authenticationService, authenticationService,
) )
authenticationService.givenLoginError(A_THROWABLE) authenticationService.givenLoginError(A_THROWABLE)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()

View file

@ -16,7 +16,7 @@
package io.element.android.features.login.impl.screens.changeaccountprovider package io.element.android.features.login.impl.screens.changeaccountprovider
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow 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
@ -37,7 +37,7 @@ class ChangeAccountProviderPresenterTest {
val presenter = ChangeAccountProviderPresenter( val presenter = ChangeAccountProviderPresenter(
changeServerPresenter changeServerPresenter
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()

View file

@ -16,7 +16,7 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider package io.element.android.features.login.impl.screens.confirmaccountprovider
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow 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
@ -38,7 +38,7 @@ class ConfirmAccountProviderPresenterTest {
AccountProviderDataSource(), AccountProviderDataSource(),
FakeAuthenticationService(), FakeAuthenticationService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -58,7 +58,7 @@ class ConfirmAccountProviderPresenterTest {
authServer, authServer,
) )
authServer.givenHomeserver(A_HOMESERVER) authServer.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -82,7 +82,7 @@ class ConfirmAccountProviderPresenterTest {
authServer, authServer,
) )
authServer.givenHomeserver(A_HOMESERVER_OIDC) authServer.givenHomeserver(A_HOMESERVER_OIDC)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -105,7 +105,7 @@ class ConfirmAccountProviderPresenterTest {
AccountProviderDataSource(), AccountProviderDataSource(),
authServer, authServer,
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -126,7 +126,7 @@ class ConfirmAccountProviderPresenterTest {
AccountProviderDataSource(), AccountProviderDataSource(),
authenticationService, authenticationService,
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()

View file

@ -16,7 +16,7 @@
package io.element.android.features.login.impl.screens.loginpassword package io.element.android.features.login.impl.screens.loginpassword
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow 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
@ -45,7 +45,7 @@ class LoginPasswordPresenterTest {
accountProviderDataSource, accountProviderDataSource,
loginUserStory, loginUserStory,
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -67,7 +67,7 @@ class LoginPasswordPresenterTest {
loginUserStory, loginUserStory,
) )
authenticationService.givenHomeserver(A_HOMESERVER) authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -93,7 +93,7 @@ class LoginPasswordPresenterTest {
loginUserStory, loginUserStory,
) )
authenticationService.givenHomeserver(A_HOMESERVER) authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
assertThat(loginUserStory.loginFlowIsDone.value).isFalse() assertThat(loginUserStory.loginFlowIsDone.value).isFalse()
@ -122,7 +122,7 @@ class LoginPasswordPresenterTest {
loginUserStory, loginUserStory,
) )
authenticationService.givenHomeserver(A_HOMESERVER) authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -150,7 +150,7 @@ class LoginPasswordPresenterTest {
loginUserStory, loginUserStory,
) )
authenticationService.givenHomeserver(A_HOMESERVER) authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()

View file

@ -16,7 +16,7 @@
package io.element.android.features.login.impl.screens.searchaccountprovider package io.element.android.features.login.impl.screens.searchaccountprovider
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow 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
@ -46,7 +46,7 @@ class SearchAccountProviderPresenterTest {
HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest), HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
changeServerPresenter changeServerPresenter
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -66,7 +66,7 @@ class SearchAccountProviderPresenterTest {
HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest), HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
changeServerPresenter changeServerPresenter
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -90,7 +90,7 @@ class SearchAccountProviderPresenterTest {
HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest), HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
changeServerPresenter changeServerPresenter
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -125,7 +125,7 @@ class SearchAccountProviderPresenterTest {
HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest), HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
changeServerPresenter changeServerPresenter
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -160,7 +160,7 @@ class SearchAccountProviderPresenterTest {
HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest), HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
changeServerPresenter changeServerPresenter
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()

View file

@ -16,7 +16,7 @@
package io.element.android.features.login.impl.screens.waitlistscreen package io.element.android.features.login.impl.screens.waitlistscreen
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow 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
@ -46,7 +46,7 @@ class WaitListPresenterTest {
authenticationService, authenticationService,
loginUserStory, loginUserStory,
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -68,7 +68,7 @@ class WaitListPresenterTest {
authenticationService, authenticationService,
loginUserStory, loginUserStory,
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -97,7 +97,7 @@ class WaitListPresenterTest {
authenticationService, authenticationService,
loginUserStory, loginUserStory,
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
assertThat(loginUserStory.loginFlowIsDone.value).isFalse() assertThat(loginUserStory.loginFlowIsDone.value).isFalse()

View file

@ -48,6 +48,4 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
androidTestImplementation(libs.test.junitext)
} }

View file

@ -16,7 +16,7 @@
package io.element.android.features.logout.impl package io.element.android.features.logout.impl
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow 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
@ -34,7 +34,7 @@ class LogoutPreferencePresenterTest {
val presenter = DefaultLogoutPreferencePresenter( val presenter = DefaultLogoutPreferencePresenter(
FakeMatrixClient(), FakeMatrixClient(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -47,7 +47,7 @@ class LogoutPreferencePresenterTest {
val presenter = DefaultLogoutPreferencePresenter( val presenter = DefaultLogoutPreferencePresenter(
FakeMatrixClient(), FakeMatrixClient(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -65,7 +65,7 @@ class LogoutPreferencePresenterTest {
val presenter = DefaultLogoutPreferencePresenter( val presenter = DefaultLogoutPreferencePresenter(
matrixClient, matrixClient,
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()

View file

@ -78,6 +78,5 @@ dependencies {
testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediapickers.test)
testImplementation(libs.test.mockk) testImplementation(libs.test.mockk)
androidTestImplementation(libs.test.junitext)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
} }

View file

@ -30,7 +30,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
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.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch

View file

@ -82,6 +82,7 @@ fun ForwardMessagesView(
return return
} }
@Suppress("UNUSED_PARAMETER")
fun onRoomRemoved(roomSummaryDetails: RoomSummaryDetails) { fun onRoomRemoved(roomSummaryDetails: RoomSummaryDetails) {
// TODO toggle selection when multi-selection is enabled // TODO toggle selection when multi-selection is enabled
state.eventSink(ForwardMessagesEvents.RemoveSelectedRoom) state.eventSink(ForwardMessagesEvents.RemoveSelectedRoom)

View file

@ -57,7 +57,6 @@ import io.element.android.features.messages.impl.media.local.LocalMediaView
import io.element.android.features.messages.impl.media.local.MediaInfo import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.features.messages.impl.media.local.rememberLocalMediaViewState import io.element.android.features.messages.impl.media.local.rememberLocalMediaViewState
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark

View file

@ -83,7 +83,7 @@ internal fun AttachmentsBottomSheet(
onDismissRequest = { isVisible = false } onDismissRequest = { isVisible = false }
) { ) {
AttachmentSourcePickerMenu( AttachmentSourcePickerMenu(
eventSink = state.eventSink, state = state,
onSendLocationClicked = onSendLocationClicked, onSendLocationClicked = onSendLocationClicked,
) )
} }
@ -93,7 +93,7 @@ internal fun AttachmentsBottomSheet(
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
internal fun AttachmentSourcePickerMenu( internal fun AttachmentSourcePickerMenu(
eventSink: (MessageComposerEvents) -> Unit, state: MessageComposerState,
onSendLocationClicked: () -> Unit, onSendLocationClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -102,33 +102,35 @@ internal fun AttachmentSourcePickerMenu(
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044 // .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
) { ) {
ListItem( ListItem(
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) }, modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
icon = { Icon(Icons.Default.Collections, null) }, icon = { Icon(Icons.Default.Collections, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) }, text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
) )
ListItem( ListItem(
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) }, modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
icon = { Icon(Icons.Default.AttachFile, null) }, icon = { Icon(Icons.Default.AttachFile, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_files)) }, text = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
) )
ListItem( ListItem(
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) }, modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
icon = { Icon(Icons.Default.PhotoCamera, null) }, icon = { Icon(Icons.Default.PhotoCamera, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) }, text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
) )
ListItem( ListItem(
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) }, modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
icon = { Icon(Icons.Default.Videocam, null) }, icon = { Icon(Icons.Default.Videocam, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) }, text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
) )
ListItem( if (state.canShareLocation) {
modifier = Modifier.clickable { ListItem(
eventSink(MessageComposerEvents.PickAttachmentSource.Location) modifier = Modifier.clickable {
onSendLocationClicked() state.eventSink(MessageComposerEvents.PickAttachmentSource.Location)
}, onSendLocationClicked()
icon = { Icon(Icons.Default.LocationOn, null) }, },
text = { Text(stringResource(R.string.screen_room_attachment_source_location)) }, icon = { Icon(Icons.Default.LocationOn, null) },
) text = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
)
}
} }
} }
@ -136,7 +138,9 @@ internal fun AttachmentSourcePickerMenu(
@Composable @Composable
internal fun AttachmentSourcePickerMenuPreview() = ElementPreview { internal fun AttachmentSourcePickerMenuPreview() = ElementPreview {
AttachmentSourcePickerMenu( AttachmentSourcePickerMenu(
eventSink = {}, state = aMessageComposerState(
canShareLocation = true,
),
onSendLocationClicked = {}, onSendLocationClicked = {},
) )
} }

View file

@ -74,6 +74,11 @@ class MessageComposerPresenter @Inject constructor(
mutableStateOf<AttachmentsState>(AttachmentsState.None) mutableStateOf<AttachmentsState>(AttachmentsState.None)
} }
val canShareLocation = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
canShareLocation.value = featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)
}
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType -> val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
handlePickedMedia(attachmentsState, uri, mimeType) handlePickedMedia(attachmentsState, uri, mimeType)
} }
@ -140,23 +145,23 @@ class MessageComposerPresenter @Inject constructor(
) )
) )
} }
MessageComposerEvents.AddAttachment -> localCoroutineScope.launchIfMediaPickerEnabled { MessageComposerEvents.AddAttachment -> localCoroutineScope.launch {
showAttachmentSourcePicker = true showAttachmentSourcePicker = true
} }
MessageComposerEvents.DismissAttachmentMenu -> showAttachmentSourcePicker = false MessageComposerEvents.DismissAttachmentMenu -> showAttachmentSourcePicker = false
MessageComposerEvents.PickAttachmentSource.FromGallery -> localCoroutineScope.launchIfMediaPickerEnabled { MessageComposerEvents.PickAttachmentSource.FromGallery -> localCoroutineScope.launch {
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
galleryMediaPicker.launch() galleryMediaPicker.launch()
} }
MessageComposerEvents.PickAttachmentSource.FromFiles -> localCoroutineScope.launchIfMediaPickerEnabled { MessageComposerEvents.PickAttachmentSource.FromFiles -> localCoroutineScope.launch {
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
filesPicker.launch() filesPicker.launch()
} }
MessageComposerEvents.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launchIfMediaPickerEnabled { MessageComposerEvents.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launch {
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
cameraPhotoPicker.launch() cameraPhotoPicker.launch()
} }
MessageComposerEvents.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launchIfMediaPickerEnabled { MessageComposerEvents.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launch {
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
cameraVideoPicker.launch() cameraVideoPicker.launch()
} }
@ -173,17 +178,12 @@ class MessageComposerPresenter @Inject constructor(
hasFocus = hasFocus.value, hasFocus = hasFocus.value,
mode = messageComposerContext.composerMode, mode = messageComposerContext.composerMode,
showAttachmentSourcePicker = showAttachmentSourcePicker, showAttachmentSourcePicker = showAttachmentSourcePicker,
canShareLocation = canShareLocation.value,
attachmentsState = attachmentsState.value, attachmentsState = attachmentsState.value,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
private fun CoroutineScope.launchIfMediaPickerEnabled(action: suspend () -> Unit) = launch {
if (featureFlagService.isFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow)) {
action()
}
}
private fun CoroutineScope.sendMessage( private fun CoroutineScope.sendMessage(
text: String, text: String,
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit, updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,

View file

@ -28,6 +28,7 @@ data class MessageComposerState(
val hasFocus: Boolean, val hasFocus: Boolean,
val mode: MessageComposerMode, val mode: MessageComposerMode,
val showAttachmentSourcePicker: Boolean, val showAttachmentSourcePicker: Boolean,
val canShareLocation: Boolean,
val attachmentsState: AttachmentsState, val attachmentsState: AttachmentsState,
val eventSink: (MessageComposerEvents) -> Unit val eventSink: (MessageComposerEvents) -> Unit
) { ) {

View file

@ -26,12 +26,21 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
) )
} }
fun aMessageComposerState() = MessageComposerState( fun aMessageComposerState(
text = "", text: String = "",
isFullScreen = false, isFullScreen: Boolean = false,
hasFocus = false, hasFocus: Boolean = false,
mode = MessageComposerMode.Normal(content = ""), mode: MessageComposerMode = MessageComposerMode.Normal(content = ""),
showAttachmentSourcePicker = false, showAttachmentSourcePicker: Boolean = false,
attachmentsState = AttachmentsState.None, canShareLocation: Boolean = true,
eventSink = {} attachmentsState: AttachmentsState = AttachmentsState.None,
) = MessageComposerState(
text = text,
isFullScreen = isFullScreen,
hasFocus = hasFocus,
mode = mode,
showAttachmentSourcePicker = showAttachmentSourcePicker,
canShareLocation = canShareLocation,
attachmentsState = attachmentsState,
eventSink = {},
) )

View file

@ -42,8 +42,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
private const val backPaginationEventLimit = 20 private const val BACK_PAGINATION_EVENT_LIMIT = 20
private const val backPaginationPageSize = 50 private const val BACK_PAGINATION_PAGE_SIZE = 50
class TimelinePresenter @Inject constructor( class TimelinePresenter @Inject constructor(
private val timelineItemsFactory: TimelineItemsFactory, private val timelineItemsFactory: TimelineItemsFactory,
@ -164,6 +164,6 @@ class TimelinePresenter @Inject constructor(
} }
private fun CoroutineScope.paginateBackwards() = launch { private fun CoroutineScope.paginateBackwards() = launch {
timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize) timeline.paginateBackwards(BACK_PAGINATION_EVENT_LIMIT, BACK_PAGINATION_PAGE_SIZE)
} }
} }

View file

@ -94,6 +94,7 @@ fun TimelineView(
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
@Suppress("UNUSED_PARAMETER")
fun inReplyToClicked(eventId: EventId) { fun inReplyToClicked(eventId: EventId) {
// TODO implement this logic once we have support to 'jump to event X' in sliding sync // TODO implement this logic once we have support to 'jump to event X' in sliding sync
} }

View file

@ -41,7 +41,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vanniktech.emoji.Emoji import com.vanniktech.emoji.Emoji
import com.vanniktech.emoji.google.GoogleEmojiProvider import com.vanniktech.emoji.google.GoogleEmojiProvider
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@ -62,7 +61,7 @@ fun EmojiPicker(
val emojiProvider = remember { GoogleEmojiProvider() } val emojiProvider = remember { GoogleEmojiProvider() }
val categories = remember { emojiProvider.categories } val categories = remember { emojiProvider.categories }
val pagerState = rememberPagerState() val pagerState = rememberPagerState()
Column (modifier) { Column(modifier) {
TabRow( TabRow(
selectedTabIndex = pagerState.currentPage, selectedTabIndex = pagerState.currentPage,
) { ) {
@ -109,7 +108,8 @@ fun EmojiPicker(
Text( Text(
text = item.unicode, text = item.unicode,
style = ElementTheme.typography.fontHeadingSmRegular, style = ElementTheme.typography.fontHeadingSmRegular,
) } )
}
} }
} }
} }

View file

@ -35,7 +35,6 @@ import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleStateProvider import io.element.android.features.messages.impl.timeline.model.bubble.BubbleStateProvider

View file

@ -33,7 +33,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Surface
private val CORNER_RADIUS = 8.dp private val CORNER_RADIUS = 8.dp

View file

@ -117,6 +117,7 @@ private fun TextContent(
.height(reactionEmojiLineHeight.toDp()), .height(reactionEmojiLineHeight.toDp()),
text = text, text = text,
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.materialColors.primary
) )
@Composable @Composable
@ -126,7 +127,7 @@ private fun IconContent(
) = Icon( ) = Icon(
imageVector = imageVector, imageVector = imageVector,
contentDescription = stringResource(id = R.string.screen_room_timeline_add_reaction), contentDescription = stringResource(id = R.string.screen_room_timeline_add_reaction),
tint = MaterialTheme.colorScheme.secondary, tint = ElementTheme.materialColors.secondary,
modifier = modifier modifier = modifier
.size(reactionEmojiLineHeight.toDp()) .size(reactionEmojiLineHeight.toDp())
) )

View file

@ -59,7 +59,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.constraintlayout.compose.ConstrainScope import androidx.constraintlayout.compose.ConstrainScope
import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.ConstraintLayout
import com.google.accompanist.flowlayout.FlowMainAxisAlignment
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
@ -291,7 +290,7 @@ private fun TimelineItemEventRowContent(
if (event.reactionsState.reactions.isNotEmpty()) { if (event.reactionsState.reactions.isNotEmpty()) {
TimelineItemReactions( TimelineItemReactions(
reactionsState = event.reactionsState, reactionsState = event.reactionsState,
mainAxisAlignment = if (event.isMine) FlowMainAxisAlignment.End else FlowMainAxisAlignment.Start, isOutgoing = event.isMine,
onReactionClicked = onReactionClicked, onReactionClicked = onReactionClicked,
onMoreReactionsClicked = { onMoreReactionsClicked(event) }, onMoreReactionsClicked = { onMoreReactionsClicked(event) },
modifier = Modifier modifier = Modifier

View file

@ -0,0 +1,208 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddReaction
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
/**
* A flow layout for reactions that will show a collapse/expand button when the layout wraps over a defined number of rows.
* It displays an add more button when there are greater than 0 reactions and always displays the reaction and add more button
* on the same row (moving them both to a new row if necessary).
* @param expandButton The expand button
* @param addMoreButton The add more button
* @param modifier The modifier to apply to this layout
* @param itemSpacing The horizontal spacing between items
* @param rowSpacing The vertical spacing between rows
* @param expanded Whether the layout should display in expanded or collapsed state
* @param rowsBeforeCollapsible The number of rows before the collapse/expand button is shown
* @param reactions The reaction buttons
*/
@Composable
fun TimelineItemReactionsLayout(
expandButton: @Composable () -> Unit,
addMoreButton: @Composable () -> Unit,
modifier: Modifier = Modifier,
itemSpacing: Dp = 0.dp,
rowSpacing: Dp = 0.dp,
expanded: Boolean = false,
rowsBeforeCollapsible: Int? = 2,
reactions: @Composable () -> Unit,
) {
SubcomposeLayout(modifier) { constraints ->
// Given the placeables and returns a structure representing
// how they should wrap on to multiple rows given the constraints max width.
fun calculateRows(measurables: List<Placeable>): List<List<Placeable>> {
val rows = mutableListOf<List<Placeable>>()
var currentRow = mutableListOf<Placeable>()
var rowX = 0
measurables.forEach { placeable ->
val horizontalSpacing = if (currentRow.isEmpty()) 0 else itemSpacing.toPx().toInt()
// If the current view does not fit on this row bump to the next
if (rowX + placeable.width > constraints.maxWidth) {
rows.add(currentRow)
currentRow = mutableListOf()
rowX = 0
}
rowX += horizontalSpacing + placeable.width
currentRow.add(placeable)
}
// If there are items in the current row remember to append it to the returned value
if (currentRow.size > 0) {
rows.add(currentRow)
}
return rows
}
// Used to render the collapsed state, this takes the rows inputted and adds the extra button to the last row,
// removing only as many trailing reactions as needed to make space for it.
fun replaceTrailingItemsWithButtons(rowsIn: List<List<Placeable>>, expandButton: Placeable, addMoreButton: Placeable): List<List<Placeable>> {
val rows = rowsIn.toMutableList()
val lastRow = rows.last()
val buttonsWidth = expandButton.width + itemSpacing.toPx().toInt() + addMoreButton.width
var rowX = 0
lastRow.forEachIndexed { i, placeable ->
val horizontalSpacing = if (i == 0) 0 else itemSpacing.toPx().toInt()
rowX += placeable.width + horizontalSpacing
if (rowX > constraints.maxWidth - (buttonsWidth + horizontalSpacing)) {
val lastRowWithButton = lastRow.take(i) + listOf(expandButton, addMoreButton)
rows[rows.size - 1] = lastRowWithButton
return rows
}
}
val lastRowWithButton = lastRow + listOf(expandButton, addMoreButton)
rows[rows.size - 1] = lastRowWithButton
return rows
}
// To prevent the add more and expand buttons from wrapping on to separate lines.
// If there is one item on the last line, it moves the expand button down.
fun ensureCollapseAndAddMoreButtonsAreOnTheSameRow(rowsIn: List<List<Placeable>>): List<List<Placeable>> {
val lastRow = rowsIn.last().toMutableList()
if (lastRow.size != 1) {
return rowsIn
}
val rows = rowsIn.toMutableList()
val secondLastRow = rows[rows.size - 2].toMutableList()
val expandButtonPlaceable = secondLastRow.removeLast()
lastRow.add(0, expandButtonPlaceable)
rows[rows.size - 2] = secondLastRow
rows[rows.size - 1] = lastRow
return rows
}
/// Given a list of rows place them in the layout.
fun layoutRows(rows: List<List<Placeable>>): MeasureResult {
var width = 0
var height = 0
val placeables = rows.mapIndexed { i, row ->
var rowX = 0
var rowHeight = 0
val verticalSpacing = if (i == 0) 0 else rowSpacing.toPx().toInt()
val rowWithPoints = row.mapIndexed { j, placeable ->
val horizontalSpacing = if (j == 0) 0 else itemSpacing.toPx().toInt()
val point = IntOffset(rowX + horizontalSpacing, height + verticalSpacing)
rowX += placeable.width + horizontalSpacing
rowHeight = maxOf(rowHeight, placeable.height)
Pair(placeable, point)
}
height += rowHeight + verticalSpacing
width = maxOf(width, rowX)
rowWithPoints
}.flatten()
return layout(width = width, height = height) {
placeables.forEach {
val (placeable, origin) = it
placeable.placeRelative(origin.x, origin.y)
}
}
}
val reactionsPlaceables = subcompose(0, reactions).map { it.measure(constraints) }
if (reactionsPlaceables.isEmpty()) {
return@SubcomposeLayout layoutRows(listOf())
}
val addMorePlaceable = subcompose(1, addMoreButton).first().measure(constraints)
val expandPlaceable = subcompose(2, expandButton).first().measure(constraints)
// Calculate the layout of the rows with the reactions button and add more button
val reactionsAndAddMore = calculateRows(reactionsPlaceables + listOf(addMorePlaceable))
// If we have extended beyond the defined number of rows we are showing the expand/collapse ui
if (rowsBeforeCollapsible?.let { reactionsAndAddMore.size > it } == true) {
if (expanded) {
// Show all subviews with the add more button at the end
var reactionsAndButtons = calculateRows(reactionsPlaceables + listOf(expandPlaceable, addMorePlaceable))
reactionsAndButtons = ensureCollapseAndAddMoreButtonsAreOnTheSameRow(reactionsAndButtons)
layoutRows(reactionsAndButtons)
} else {
// Truncate to `rowsBeforeCollapsible` number of rows and replace the reactions at the end of the last row with the buttons
val collapsedRows = reactionsAndAddMore.take(rowsBeforeCollapsible)
val collapsedRowsWithButtons = replaceTrailingItemsWithButtons(collapsedRows, expandPlaceable, addMorePlaceable)
layoutRows(collapsedRowsWithButtons)
}
} else {
// Otherwise we are just showing all items without the expand button
layoutRows(reactionsAndAddMore)
}
}
}
@DayNightPreviews
@Composable
internal fun TimelineItemReactionsLayoutPreview() = ElementPreview {
TimelineItemReactionsLayout(
expanded = false,
expandButton = {
MessagesReactionButton(
content = MessagesReactionsButtonContent.Text(
text = stringResource(id = R.string.screen_room_timeline_less_reactions)
),
onClick = { },
)
},
addMoreButton = {
MessagesReactionButton(
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
onClick = {}
)
},
reactions = {
io.element.android.features.messages.impl.timeline.aTimelineItemReactions(count = 18).reactions.forEach {
MessagesReactionButton(
content = MessagesReactionsButtonContent.Reaction(
it
),
onClick = {}
)
}
}
)
}

View file

@ -19,18 +19,16 @@ package io.element.android.features.messages.impl.timeline.components
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddReaction import androidx.compose.material.icons.outlined.AddReaction
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.accompanist.flowlayout.FlowMainAxisAlignment
import com.google.accompanist.flowlayout.FlowRow
import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
@ -38,162 +36,119 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemReac
import io.element.android.libraries.designsystem.preview.DayNightPreviews import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
/**
* The maximum number of items that can be displayed before some items will be hidden
*
* TODO The threshold should be based on the number of rows, rather than items.
* Once items would spill onto a third row, they should be hidden.
* Note this could be particularly worthwhile to handle reactions that are
* longer than a single character (as annotation keys are free text).
*/
private const val COLLAPSE_ITEMS_THRESHOLD = 8
@Composable @Composable
fun TimelineItemReactions( fun TimelineItemReactions(
reactionsState: TimelineItemReactions, reactionsState: TimelineItemReactions,
mainAxisAlignment: FlowMainAxisAlignment, isOutgoing: Boolean,
onReactionClicked: (emoji: String) -> Unit, onReactionClicked: (emoji: String) -> Unit,
onMoreReactionsClicked: () -> Unit, onMoreReactionsClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var expanded: Boolean by rememberSaveable { mutableStateOf(false) } var expanded: Boolean by rememberSaveable { mutableStateOf(false) }
val reactions by remember(reactionsState, expanded) { // In LTR languages we want an incoming message's reactions to be LRT and outgoing to be RTL.
derivedStateOf { // For RTL languages it should be the opposite.
val numToDisplay = if (expanded) { val reactionsLayoutDirection = if (!isOutgoing) LocalLayoutDirection.current
reactionsState.reactions.count() else if (LocalLayoutDirection.current == LayoutDirection.Ltr)
} else { LayoutDirection.Rtl
COLLAPSE_ITEMS_THRESHOLD else
} LayoutDirection.Ltr
reactionsState.reactions.take(numToDisplay).toPersistentList()
} CompositionLocalProvider(LocalLayoutDirection provides reactionsLayoutDirection) {
TimelineItemReactionsView(
modifier = modifier,
reactions = reactionsState.reactions,
expanded = expanded,
onReactionClick = onReactionClicked,
onMoreReactionsClick = onMoreReactionsClicked,
onToggleExpandClick = { expanded = !expanded },
)
} }
val expandableState by remember {
derivedStateOf {
if (expanded) {
ExpandableState.Expanded
} else {
val hiddenItems = reactionsState.reactions.count() - reactions.count()
if (hiddenItems > 0) {
ExpandableState.Collapsed(hidden = hiddenItems)
} else {
ExpandableState.None
}
}
}
}
TimelineItemReactionsView(
modifier = modifier,
reactions = reactions,
expandableState = expandableState,
mainAxisAlignment = mainAxisAlignment,
onReactionClick = onReactionClicked,
onMoreReactionsClick = onMoreReactionsClicked,
onExpandClick = { expanded = true },
onCollapseClick = { expanded = false }
)
}
private sealed class ExpandableState {
object None: ExpandableState()
data class Collapsed(val hidden: Int): ExpandableState()
object Expanded : ExpandableState()
} }
@Composable @Composable
private fun TimelineItemReactionsView( private fun TimelineItemReactionsView(
reactions: ImmutableList<AggregatedReaction>, reactions: ImmutableList<AggregatedReaction>,
expandableState: ExpandableState, expanded: Boolean,
mainAxisAlignment: FlowMainAxisAlignment,
onReactionClick: (emoji: String) -> Unit, onReactionClick: (emoji: String) -> Unit,
onMoreReactionsClick: () -> Unit, onMoreReactionsClick: () -> Unit,
onExpandClick: () -> Unit, onToggleExpandClick: () -> Unit,
onCollapseClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) = ) = TimelineItemReactionsLayout(
FlowRow( modifier = modifier,
modifier = modifier, itemSpacing = 4.dp,
mainAxisSpacing = 4.dp, rowSpacing = 4.dp,
crossAxisSpacing = 4.dp, expanded = expanded,
mainAxisAlignment = mainAxisAlignment, expandButton = {
) { MessagesReactionButton(
content = MessagesReactionsButtonContent.Text(
text = stringResource(id = if (expanded) R.string.screen_room_reactions_show_less else R.string.screen_room_reactions_show_more)
),
onClick = onToggleExpandClick,
)
},
addMoreButton = {
MessagesReactionButton(
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
onClick = onMoreReactionsClick
)
},
reactions = {
reactions.forEach { reaction -> reactions.forEach { reaction ->
MessagesReactionButton( MessagesReactionButton(
content = MessagesReactionsButtonContent.Reaction(reaction = reaction), content = MessagesReactionsButtonContent.Reaction(reaction = reaction),
onClick = { onReactionClick(reaction.key) } onClick = { onReactionClick(reaction.key) }
) )
} }
when (expandableState) {
ExpandableState.Expanded ->
MessagesReactionButton(
content = MessagesReactionsButtonContent.Text(
text = stringResource(id = R.string.screen_room_timeline_less_reactions)
),
onClick = onCollapseClick,
)
is ExpandableState.Collapsed -> {
val hidden = expandableState.hidden
MessagesReactionButton(
content = MessagesReactionsButtonContent.Text(
text = pluralStringResource(id = R.plurals.screen_room_timeline_more_reactions, hidden, hidden)
),
onClick = onExpandClick,
)
}
ExpandableState.None -> {
// No expand or collapse action available
}
}
MessagesReactionButton(
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
onClick = onMoreReactionsClick
)
} }
)
@DayNightPreviews @DayNightPreviews
@Composable @Composable
fun TimelineItemReactionsViewPreview() = ElementPreview { fun TimelineItemReactionsViewPreview() = ElementPreview {
ContentToPreview( ContentToPreview(
reactions = aTimelineItemReactions(count = 1).reactions, reactions = aTimelineItemReactions(count = 1).reactions
expandableState = ExpandableState.None,
) )
} }
@DayNightPreviews @DayNightPreviews
@Composable @Composable
fun TimelineItemReactionsViewCollapsedPreview() = ElementPreview { fun TimelineItemReactionsViewFewPreview() = ElementPreview {
ContentToPreview( ContentToPreview(
reactions = aTimelineItemReactions(count = 3).reactions, reactions = aTimelineItemReactions(count = 3).reactions
expandableState = ExpandableState.Collapsed(hidden = 7),
) )
} }
@DayNightPreviews @DayNightPreviews
@Composable @Composable
fun TimelineItemReactionsViewExpandedPreview() = ElementPreview { fun TimelineItemReactionsViewIncomingPreview() = ElementPreview {
ContentToPreview( ContentToPreview(
reactions = aTimelineItemReactions(count = 10).reactions, reactions = aTimelineItemReactions(count = 18).reactions
expandableState = ExpandableState.Expanded, )
}
@DayNightPreviews
@Composable
fun TimelineItemReactionsViewOutgoingPreview() = ElementPreview {
ContentToPreview(
reactions = aTimelineItemReactions(count = 18).reactions,
isOutgoing = true
) )
} }
@Composable @Composable
private fun ContentToPreview( private fun ContentToPreview(
reactions: ImmutableList<AggregatedReaction>, reactions: ImmutableList<AggregatedReaction>,
expandableState: ExpandableState isOutgoing: Boolean = false
) { ) {
TimelineItemReactionsView( TimelineItemReactions(
reactions = reactions, reactionsState = TimelineItemReactions(
expandableState = expandableState, reactions
mainAxisAlignment = FlowMainAxisAlignment.Center, ),
onReactionClick = {}, isOutgoing = isOutgoing,
onMoreReactionsClick = {}, onReactionClicked = {},
onExpandClick = {}, onMoreReactionsClicked = {},
onCollapseClick = {}
) )
} }

View file

@ -65,7 +65,7 @@ fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
fun ExtraPadding.getStr(fontSize: TextUnit): String { fun ExtraPadding.getStr(fontSize: TextUnit): String {
if (nbChars == 0) return "" if (nbChars == 0) return ""
val timestampFontSize = ElementTheme.typography.fontBodyXsRegular.fontSize // 11.sp val timestampFontSize = ElementTheme.typography.fontBodyXsRegular.fontSize // 11.sp
val nbOfSpaces = ((timestampFontSize.value / fontSize.value) * nbChars).toInt() + 1 val nbOfSpaces = (timestampFontSize.value / fontSize.value * nbChars).toInt() + 1
// A space and some unbreakable spaces // A space and some unbreakable spaces
return " " + "\u00A0".repeat(nbOfSpaces) return " " + "\u00A0".repeat(nbOfSpaces)
} }

View file

@ -30,7 +30,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun TimelineItemEncryptedView( fun TimelineItemEncryptedView(
content: TimelineItemEncryptedContent, @Suppress("UNUSED_PARAMETER") content: TimelineItemEncryptedContent,
extraPadding: ExtraPadding, extraPadding: ExtraPadding,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {

View file

@ -29,7 +29,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun TimelineItemRedactedView( fun TimelineItemRedactedView(
content: TimelineItemRedactedContent, @Suppress("UNUSED_PARAMETER") content: TimelineItemRedactedContent,
extraPadding: ExtraPadding, extraPadding: ExtraPadding,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {

View file

@ -29,7 +29,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun TimelineItemUnknownView( fun TimelineItemUnknownView(
content: TimelineItemUnknownContent, @Suppress("UNUSED_PARAMETER") content: TimelineItemUnknownContent,
extraPadding: ExtraPadding, extraPadding: ExtraPadding,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {

View file

@ -18,7 +18,6 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable

View file

@ -35,7 +35,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon

View file

@ -61,7 +61,7 @@ import org.jsoup.nodes.Element
import org.jsoup.nodes.Node import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode import org.jsoup.nodes.TextNode
private const val chipId = "chip" private const val CHIP_ID = "chip"
@Composable @Composable
fun HtmlDocument( fun HtmlDocument(
@ -351,7 +351,7 @@ private fun HtmlMxReply(
Surface( Surface(
modifier = modifier modifier = modifier
.padding(bottom = 4.dp) .padding(bottom = 4.dp)
.offset(x = -(8.dp)), .offset(x = (-8).dp),
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
shape = shape, shape = shape,
) { ) {
@ -544,17 +544,27 @@ private fun AnnotatedString.Builder.appendLink(link: Element) {
pop() pop()
} }
is PermalinkData.RoomEmailInviteLink -> { is PermalinkData.RoomEmailInviteLink -> {
appendInlineContent(chipId, link.ownText()) safeAppendInlineContent(CHIP_ID, link.ownText())
} }
is PermalinkData.RoomLink -> { is PermalinkData.RoomLink -> {
appendInlineContent(chipId, link.ownText()) safeAppendInlineContent(CHIP_ID, link.ownText())
} }
is PermalinkData.UserLink -> { is PermalinkData.UserLink -> {
appendInlineContent(chipId, link.ownText()) safeAppendInlineContent(CHIP_ID, link.ownText())
} }
} }
} }
fun AnnotatedString.Builder.safeAppendInlineContent(chipId: String, ownText: String) {
if (ownText.isEmpty()) {
// alternateText cannot be empty and default parameter value is private,
// so just omit the second param here.
appendInlineContent(chipId)
} else {
appendInlineContent(chipId, ownText)
}
}
@Composable @Composable
private fun HtmlText( private fun HtmlText(
text: AnnotatedString, text: AnnotatedString,

View file

@ -31,7 +31,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
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.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -165,6 +164,7 @@ internal fun RetrySendMessageMenuPreviewDark(@PreviewParameter(RetrySendMenuStat
} }
} }
@Suppress("UNUSED_PARAMETER")
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ContentToPreview(state: RetrySendMenuState) { private fun ContentToPreview(state: RetrySendMenuState) {

View file

@ -30,7 +30,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.preview.DayNightPreviews import io.element.android.libraries.designsystem.preview.DayNightPreviews

View file

@ -24,7 +24,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp

View file

@ -23,7 +23,7 @@ import javax.inject.Inject
class TimelineItemContentFailedToParseMessageFactory @Inject constructor() { class TimelineItemContentFailedToParseMessageFactory @Inject constructor() {
fun create(failedToParseMessageLike: FailedToParseMessageLikeContent): TimelineItemEventContent { fun create(@Suppress("UNUSED_PARAMETER") failedToParseMessageLike: FailedToParseMessageLikeContent): TimelineItemEventContent {
return TimelineItemUnknownContent return TimelineItemUnknownContent
} }
} }

View file

@ -23,6 +23,7 @@ import javax.inject.Inject
class TimelineItemContentFailedToParseStateFactory @Inject constructor() { class TimelineItemContentFailedToParseStateFactory @Inject constructor() {
@Suppress("UNUSED_PARAMETER")
fun create(failedToParseState: FailedToParseStateContent): TimelineItemEventContent { fun create(failedToParseState: FailedToParseStateContent): TimelineItemEventContent {
return TimelineItemUnknownContent return TimelineItemUnknownContent
} }

View file

@ -23,7 +23,7 @@ import javax.inject.Inject
class TimelineItemContentRedactedFactory @Inject constructor() { class TimelineItemContentRedactedFactory @Inject constructor() {
fun create(content: RedactedContent): TimelineItemEventContent { fun create(@Suppress("UNUSED_PARAMETER") content: RedactedContent): TimelineItemEventContent {
return TimelineItemRedactedContent return TimelineItemRedactedContent
} }
} }

View file

@ -23,7 +23,7 @@ import javax.inject.Inject
class TimelineItemContentStickerFactory @Inject constructor() { class TimelineItemContentStickerFactory @Inject constructor() {
fun create(content: StickerContent): TimelineItemEventContent { fun create(@Suppress("UNUSED_PARAMETER") content: StickerContent): TimelineItemEventContent {
return TimelineItemUnknownContent return TimelineItemUnknownContent
} }
} }

View file

@ -5,7 +5,7 @@
<item quantity="other">"%1$d changements dans la conversation"</item> <item quantity="other">"%1$d changements dans la conversation"</item>
</plurals> </plurals>
<plurals name="screen_room_timeline_more_reactions"> <plurals name="screen_room_timeline_more_reactions">
<item quantity="one"></item> <item quantity="one">"1 de plus"</item>
<item quantity="other">"%1$d de plus"</item> <item quantity="other">"%1$d de plus"</item>
</plurals> </plurals>
<string name="screen_room_attachment_source_camera">"Appareil photo"</string> <string name="screen_room_attachment_source_camera">"Appareil photo"</string>

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