Merge branch 'develop' into feature/fga/avoid_deadlocks

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

View file

@ -4,7 +4,7 @@ on:
workflow_dispatch:
pull_request: { }
push:
branches: [ main, develop ]
branches: [ develop ]
# Enrich gradle.properties for CI/CD
env:
@ -34,13 +34,15 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.6.1
uses: gradle/gradle-build-action@v2.7.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK
env:
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
uses: actions/upload-artifact@v3
with:
@ -70,8 +72,8 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Compile release sources
run: ./gradlew compileReleaseSources $CI_GRADLE_ARG_PROPERTIES
run: ./gradlew compileReleaseSources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Compile nightly sources
run: ./gradlew compileNightlySources $CI_GRADLE_ARG_PROPERTIES
run: ./gradlew compileNightlySources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Compile samples minimal
run: ./gradlew :samples:minimal:assemble $CI_GRADLE_ARG_PROPERTIES

View file

@ -38,6 +38,8 @@ jobs:
run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES
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 }}
- name: Upload debug APKs
uses: actions/upload-artifact@v3
with:
@ -47,7 +49,9 @@ jobs:
- uses: mobile-dev-inc/action-maestro-cloud@v1.4.1
with:
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: |
USERNAME=maestroelement
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:
workflow_dispatch:
@ -12,7 +12,7 @@ env:
jobs:
nightly:
name: Build and publish nightly APK to Firebase
name: Build and publish nightly bundle to Firebase
runs-on: ubuntu-latest
if: ${{ github.repository == 'vector-im/element-x-android' }}
steps:
@ -31,18 +31,21 @@ jobs:
sed 's/CHANGES\.md/CHANGES_NIGHTLY\.md/' towncrier.toml.bak > towncrier.toml
rm towncrier.toml.bak
yes n | towncrier build --version nightly
- name: Build and upload Nightly APK
- name: Build and upload Nightly application
run: |
./gradlew assembleNightly appDistributionUploadNightly $CI_GRADLE_ARG_PROPERTIES
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 }}
ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}
FIREBASE_TOKEN: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_FIREBASE_TOKEN }}
- name: Additionally upload Nightly APK to browserstack for testing
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:
BROWSERSTACK_USERNAME: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_USERNAME }}
BROWSERSTACK_PASSWORD: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_ACCESS_KEY }}

View file

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

View file

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

View file

@ -24,7 +24,7 @@ jobs:
java-version: '17'
# Add gradle cache, this should speed up the process
- name: Configure gradle
uses: gradle/gradle-build-action@v2.6.1
uses: gradle/gradle-build-action@v2.7.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- 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
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.6.1
uses: gradle/gradle-build-action@v2.7.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}

1
.gitignore vendored
View file

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

View file

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

View file

@ -129,6 +129,8 @@ android {
// "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."
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
releaseNotesFile = "CHANGES_NIGHTLY.md"
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.libraries.deeplink.DeeplinkData
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 javax.inject.Inject

View file

@ -20,6 +20,7 @@ import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
@ -161,13 +162,16 @@ class RoomLoadedFlowNode @AssistedInject constructor(
@Composable
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
// using the room in a child node where it's already closed.
DisposableEffect(Unit) {
inputs.room.open()
inputs.room.subscribeToSync()
onDispose {
inputs.room.close()
inputs.room.unsubscribeFromSync()
if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
inputs.room.destroy()
}
}
}
Children(

View file

@ -16,7 +16,7 @@
package io.element.android.appnav
import app.cash.molecule.RecompositionClock
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
@ -38,7 +38,7 @@ class RootPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
@ -54,7 +54,7 @@ class RootPresenterTest {
showError("Bad news", "Something bad happened")
}
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)

View file

@ -16,7 +16,7 @@
package io.element.android.appnav.loggedin
import app.cash.molecule.RecompositionClock
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
@ -34,7 +34,7 @@ class LoggedInPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()

View file

@ -90,6 +90,14 @@ allprojects {
apply {
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:

View file

@ -27,16 +27,21 @@ Place your API key in `local.properties` with the key
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
To insert the MapTiler API key when building an APK, set the
`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
If you wish to use an alternative map provider, or custom MapTiler styles,
you can customise the functions in
`features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt`.
We've kept this file small and self contained to minimise the chances of merge
collisions in forks.
If you wish to use an alternative map provider, you can provide your own implementations of
`TileServerStyleUriBuilder` and `StaticMapUrlBuilder` in
`features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/`.

View file

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

View file

@ -16,7 +16,7 @@
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.turbine.test
import com.google.common.truth.Truth.assertThat
@ -35,7 +35,7 @@ class AnalyticsOptInPresenterTest {
aBuildMeta(),
analyticsService
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -53,7 +53,7 @@ class AnalyticsOptInPresenterTest {
aBuildMeta(),
analyticsService
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()

View file

@ -16,7 +16,7 @@
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.turbine.test
import com.google.common.truth.Truth.assertThat
@ -33,7 +33,7 @@ class AnalyticsPreferencesPresenterTest {
FakeAnalyticsService(isEnabled = true, didAskUserConsent = true),
aBuildMeta()
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
@ -48,7 +48,7 @@ class AnalyticsPreferencesPresenterTest {
FakeAnalyticsService(isEnabled = false, didAskUserConsent = false),
aBuildMeta()
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -62,7 +62,7 @@ class AnalyticsPreferencesPresenterTest {
FakeAnalyticsService(isEnabled = true, didAskUserConsent = true),
aBuildMeta()
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)

View file

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

View file

@ -66,7 +66,5 @@ dependencies {
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.usersearch.test)
androidTestImplementation(libs.test.junitext)
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.tooling.preview.Preview
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.roomPrivacyItems
import io.element.android.libraries.designsystem.preview.ElementPreviewDark

View file

@ -16,7 +16,7 @@
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.turbine.test
import com.google.common.truth.Truth.assertThat
@ -43,7 +43,7 @@ class AddPeoplePresenterTests {
@Test
fun `present - initial state`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// TODO This doesn't actually test anything...

View file

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

View file

@ -16,7 +16,7 @@
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.turbine.test
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.aUserListState
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.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
@ -68,7 +66,7 @@ class CreateRoomRootPresenterTests {
@Test
fun `present - initial state`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -82,7 +80,7 @@ class CreateRoomRootPresenterTests {
@Test
fun `present - trigger create DM action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -102,7 +100,7 @@ class CreateRoomRootPresenterTests {
@Test
fun `present - creating a DM records analytics event`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -123,7 +121,7 @@ class CreateRoomRootPresenterTests {
@Test
fun `present - trigger retrieve DM action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -142,7 +140,7 @@ class CreateRoomRootPresenterTests {
@Test
fun `present - trigger retry create DM action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()

View file

@ -16,7 +16,7 @@
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.turbine.test
import com.google.common.truth.Truth.assertThat
@ -41,7 +41,7 @@ class DefaultUserListPresenterTests {
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
@ -62,7 +62,7 @@ class DefaultUserListPresenterTests {
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
@ -83,7 +83,7 @@ class DefaultUserListPresenterTests {
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
@ -119,7 +119,7 @@ class DefaultUserListPresenterTests {
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
@ -158,7 +158,7 @@ class DefaultUserListPresenterTests {
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
@ -183,7 +183,7 @@ class DefaultUserListPresenterTests {
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
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"?>
<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_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>

View file

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

View file

@ -16,7 +16,7 @@
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.turbine.test
import com.google.common.truth.Truth
@ -55,7 +55,7 @@ class InviteListPresenterTests {
val presenter = createPresenter(
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -76,7 +76,7 @@ class InviteListPresenterTests {
val presenter = createPresenter(
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val withInviteState = awaitItem()
@ -102,7 +102,7 @@ class InviteListPresenterTests {
val presenter = createPresenter(
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val withInviteState = awaitItem()
@ -131,7 +131,7 @@ class InviteListPresenterTests {
FakeAnalyticsService(),
FakeNotificationDrawerManager()
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val originalState = awaitItem()
@ -152,7 +152,7 @@ class InviteListPresenterTests {
val presenter = createPresenter(
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val originalState = awaitItem()
@ -173,7 +173,7 @@ class InviteListPresenterTests {
val presenter = createPresenter(
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val originalState = awaitItem()
@ -199,7 +199,7 @@ class InviteListPresenterTests {
val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager)
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val originalState = awaitItem()
@ -227,7 +227,7 @@ class InviteListPresenterTests {
room.givenLeaveRoomError(ex)
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val originalState = awaitItem()
@ -257,7 +257,7 @@ class InviteListPresenterTests {
room.givenLeaveRoomError(ex)
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val originalState = awaitItem()
@ -288,7 +288,7 @@ class InviteListPresenterTests {
val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager)
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val originalState = awaitItem()
@ -313,7 +313,7 @@ class InviteListPresenterTests {
room.givenJoinRoomResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val originalState = awaitItem()
@ -335,7 +335,7 @@ class InviteListPresenterTests {
room.givenJoinRoomResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val originalState = awaitItem()
@ -362,7 +362,7 @@ class InviteListPresenterTests {
FakeAnalyticsService(),
FakeNotificationDrawerManager()
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem()
@ -400,7 +400,7 @@ class InviteListPresenterTests {
FakeAnalyticsService(),
FakeNotificationDrawerManager()
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem()

View file

@ -16,7 +16,7 @@
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.turbine.test
import com.google.common.truth.Truth.assertThat
@ -40,7 +40,7 @@ class LeaveRoomPresenterImplTest {
@Test
fun `present - initial state hides all dialogs`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -60,7 +60,7 @@ class LeaveRoomPresenterImplTest {
)
}
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -80,7 +80,7 @@ class LeaveRoomPresenterImplTest {
)
}
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -100,7 +100,7 @@ class LeaveRoomPresenterImplTest {
)
}
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -122,7 +122,7 @@ class LeaveRoomPresenterImplTest {
},
roomMembershipObserver = roomMembershipObserver
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -145,7 +145,7 @@ class LeaveRoomPresenterImplTest {
)
}
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -167,7 +167,7 @@ class LeaveRoomPresenterImplTest {
)
}
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -191,7 +191,7 @@ class LeaveRoomPresenterImplTest {
)
}
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()

View file

@ -22,12 +22,12 @@ plugins {
id("kotlin-parcelize")
}
fun readLocalProperty(name: String) = Properties().apply {
fun readLocalProperty(name: String): String? = Properties().apply {
try {
load(rootProject.file("local.properties").reader())
} catch (ignored: java.io.IOException) {
}
}[name]
}.getProperty(name)
android {
namespace = "io.element.android.features.location.api"
@ -37,9 +37,23 @@ android {
type = "string",
name = "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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
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.staticMapUrl
import io.element.android.libraries.designsystem.preview.DayNightPreviews
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.theme.ElementTheme
import timber.log.Timber
@ -65,23 +65,22 @@ fun StaticMapView(
) {
val context = LocalContext.current
var retryHash by remember { mutableStateOf(0) }
val builder = remember { StaticMapUrlBuilder(context) }
val painter = rememberAsyncImagePainter(
model = if (constraints.isZero) {
// Avoid building a URL if any of the size constraints is zero (else it will thrown an exception).
null
} else {
ImageRequest.Builder(LocalContext.current)
ImageRequest.Builder(context)
.data(
staticMapUrl(
context = context,
builder.build(
lat = lat,
lon = lon,
zoom = zoom,
darkMode = darkMode,
// Size the map based on DP rather than pixels, as otherwise the features and attribution
// end up being illegibly tiny on high density displays.
width = constraints.maxWidth.toDp().value.toInt(),
height = constraints.maxHeight.toDp().value.toInt(),
width = constraints.maxWidth,
height = constraints.maxHeight,
density = LocalDensity.current.density,
)
)
.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, _ ->
IntOffset(
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(libs.dagger)
implementation(projects.anvilannotations)
implementation(projects.services.toolbox.api)
anvil(projects.anvilcodegen)
ksp(libs.showkase.processor)

View file

@ -20,6 +20,7 @@ import android.Manifest
import android.view.Gravity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import com.mapbox.mapboxsdk.camera.CameraPosition
import com.mapbox.mapboxsdk.geometry.LatLng
import io.element.android.libraries.maplibre.compose.MapLocationSettings
@ -53,6 +54,13 @@ object MapDefaults {
val locationSettings: MapLocationSettings
get() = MapLocationSettings(
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()

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.location.AssetType
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.systemclock.SystemClock
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
class SendLocationPresenter @Inject constructor(
@ -50,7 +45,6 @@ class SendLocationPresenter @Inject constructor(
private val analyticsService: AnalyticsService,
private val messageComposerContext: MessageComposerContext,
private val locationActions: LocationActions,
private val systemClock: SystemClock,
private val buildMeta: BuildMeta,
) : Presenter<SendLocationState> {
@ -115,7 +109,7 @@ class SendLocationPresenter @Inject constructor(
SendLocationState.Mode.PinLocation -> {
val geoUri = event.cameraPosition.toGeoUri()
room.sendLocation(
body = generateBody(geoUri, systemClock.epochMillis()),
body = generateBody(geoUri),
geoUri = geoUri,
description = null,
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
@ -134,7 +128,7 @@ class SendLocationPresenter @Inject constructor(
SendLocationState.Mode.SenderLocation -> {
val geoUri = event.toGeoUri()
room.sendLocation(
body = generateBody(geoUri, systemClock.epochMillis()),
body = generateBody(geoUri),
geoUri = geoUri,
description = null,
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 generateBody(uri: String, epochMillis: Long): String {
val timestamp = ZonedDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT)
return "Location was shared at $uri as of $timestamp"
}
private fun generateBody(uri: String): String = "Location was shared at $uri"

View file

@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
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.MyLocation
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.rememberTileStyleUrl
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.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.DayNightPreviews
@ -156,7 +156,11 @@ fun SendLocationView(
navigateUp()
},
leadingContent = {
Icon(Icons.Default.LocationOn, null)
Icon(
resourceId = R.drawable.pin_small,
contentDescription = null,
tint = Color.Unspecified,
)
},
)
Spacer(modifier = Modifier.height(16.dp + navBarPadding))

View file

@ -50,6 +50,13 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
isTrackMyLocation = false,
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(
Location(1.23, 2.34, 4f),
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 io.element.android.features.location.api.internal.rememberTileStyleUrl
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.preview.ElementPreviewDark
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
import app.cash.molecule.RecompositionClock
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
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.SendLocationInvocation
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -46,7 +45,6 @@ class SendLocationPresenterTest {
private val fakeAnalyticsService = FakeAnalyticsService()
private val messageComposerContextFake = MessageComposerContextFake()
private val fakeLocationActions = FakeLocationActions()
private val fakeSystemClock = SystemClock { 0L }
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
private val sendLocationPresenter: SendLocationPresenter = SendLocationPresenter(
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
@ -56,7 +54,6 @@ class SendLocationPresenterTest {
analyticsService = fakeAnalyticsService,
messageComposerContext = messageComposerContextFake,
locationActions = fakeLocationActions,
systemClock = fakeSystemClock,
buildMeta = fakeBuildMeta,
)
@ -69,7 +66,7 @@ class SendLocationPresenterTest {
)
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
@ -96,7 +93,7 @@ class SendLocationPresenterTest {
)
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
@ -123,7 +120,7 @@ class SendLocationPresenterTest {
)
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
val initialState = awaitItem()
@ -149,7 +146,7 @@ class SendLocationPresenterTest {
)
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
val initialState = awaitItem()
@ -175,7 +172,7 @@ class SendLocationPresenterTest {
)
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
// Skip initial state
@ -206,7 +203,7 @@ class SendLocationPresenterTest {
)
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
// Skip initial state
@ -234,7 +231,7 @@ class SendLocationPresenterTest {
)
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
// Skip initial state
@ -265,7 +262,7 @@ class SendLocationPresenterTest {
)
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
// Skip initial state
@ -292,7 +289,7 @@ class SendLocationPresenterTest {
Truth.assertThat(fakeMatrixRoom.sentLocations.size).isEqualTo(1)
Truth.assertThat(fakeMatrixRoom.sentLocations.last()).isEqualTo(
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",
description = null,
zoomLevel = 15,
@ -322,7 +319,7 @@ class SendLocationPresenterTest {
)
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
// Skip initial state
@ -349,7 +346,7 @@ class SendLocationPresenterTest {
Truth.assertThat(fakeMatrixRoom.sentLocations.size).isEqualTo(1)
Truth.assertThat(fakeMatrixRoom.sentLocations.last()).isEqualTo(
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",
description = null,
zoomLevel = 15,
@ -384,7 +381,7 @@ class SendLocationPresenterTest {
)
}
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
// Skip initial state
@ -431,7 +428,7 @@ class SendLocationPresenterTest {
)
}
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
// Skip initial state
@ -451,7 +448,7 @@ class SendLocationPresenterTest {
@Test
fun `application name is in state`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
val initialState = awaitItem()

View file

@ -16,7 +16,7 @@
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.turbine.test
import com.google.common.truth.Truth
@ -44,7 +44,7 @@ class ShowLocationPresenterTest {
@Test
fun `emits initial state with no location permission`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -59,7 +59,7 @@ class ShowLocationPresenterTest {
fun `emits initial state with location permission`() = runTest {
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted))
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -74,7 +74,7 @@ class ShowLocationPresenterTest {
fun `emits initial state with partial location permission`() = runTest {
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.SomeGranted))
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -87,7 +87,7 @@ class ShowLocationPresenterTest {
@Test
fun `uses action to share location`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -102,7 +102,7 @@ class ShowLocationPresenterTest {
fun `centers on user location`() = runTest {
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted))
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()

View file

@ -61,6 +61,4 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
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.Modifier
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.PreviewParameter
import androidx.compose.ui.unit.dp

View file

@ -30,7 +30,8 @@ data class LoginPasswordState(
) {
val submitEnabled: Boolean
get() = loginAction !is Async.Failure &&
((formState.login.isNotEmpty() && formState.password.isNotEmpty()))
formState.login.isNotEmpty() &&
formState.password.isNotEmpty()
}
@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_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_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_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_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>

View file

@ -16,7 +16,7 @@
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.turbine.test
import com.google.common.truth.Truth.assertThat
@ -36,7 +36,7 @@ class ChangeServerPresenterTest {
FakeAuthenticationService(),
AccountProviderDataSource()
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -51,7 +51,7 @@ class ChangeServerPresenterTest {
authenticationService,
AccountProviderDataSource()
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -72,7 +72,7 @@ class ChangeServerPresenterTest {
authenticationService,
AccountProviderDataSource()
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()

View file

@ -18,7 +18,7 @@
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.turbine.test
import com.google.common.truth.Truth.assertThat
@ -38,7 +38,7 @@ class OidcPresenterTest {
A_OIDC_DATA,
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -53,7 +53,7 @@ class OidcPresenterTest {
A_OIDC_DATA,
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -73,7 +73,7 @@ class OidcPresenterTest {
authenticationService,
)
authenticationService.givenOidcCancelError(A_THROWABLE)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -92,7 +92,7 @@ class OidcPresenterTest {
A_OIDC_DATA,
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -110,7 +110,7 @@ class OidcPresenterTest {
A_OIDC_DATA,
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -129,7 +129,7 @@ class OidcPresenterTest {
authenticationService,
)
authenticationService.givenLoginError(A_THROWABLE)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()

View file

@ -16,7 +16,7 @@
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.turbine.test
import com.google.common.truth.Truth.assertThat
@ -37,7 +37,7 @@ class ChangeAccountProviderPresenterTest {
val presenter = ChangeAccountProviderPresenter(
changeServerPresenter
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()

View file

@ -16,7 +16,7 @@
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.turbine.test
import com.google.common.truth.Truth.assertThat
@ -38,7 +38,7 @@ class ConfirmAccountProviderPresenterTest {
AccountProviderDataSource(),
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -58,7 +58,7 @@ class ConfirmAccountProviderPresenterTest {
authServer,
)
authServer.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -82,7 +82,7 @@ class ConfirmAccountProviderPresenterTest {
authServer,
)
authServer.givenHomeserver(A_HOMESERVER_OIDC)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -105,7 +105,7 @@ class ConfirmAccountProviderPresenterTest {
AccountProviderDataSource(),
authServer,
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -126,7 +126,7 @@ class ConfirmAccountProviderPresenterTest {
AccountProviderDataSource(),
authenticationService,
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()

View file

@ -16,7 +16,7 @@
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.turbine.test
import com.google.common.truth.Truth.assertThat
@ -45,7 +45,7 @@ class LoginPasswordPresenterTest {
accountProviderDataSource,
loginUserStory,
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -67,7 +67,7 @@ class LoginPasswordPresenterTest {
loginUserStory,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -93,7 +93,7 @@ class LoginPasswordPresenterTest {
loginUserStory,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
assertThat(loginUserStory.loginFlowIsDone.value).isFalse()
@ -122,7 +122,7 @@ class LoginPasswordPresenterTest {
loginUserStory,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -150,7 +150,7 @@ class LoginPasswordPresenterTest {
loginUserStory,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()

View file

@ -16,7 +16,7 @@
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.turbine.test
import com.google.common.truth.Truth.assertThat
@ -46,7 +46,7 @@ class SearchAccountProviderPresenterTest {
HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
changeServerPresenter
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -66,7 +66,7 @@ class SearchAccountProviderPresenterTest {
HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
changeServerPresenter
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -90,7 +90,7 @@ class SearchAccountProviderPresenterTest {
HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
changeServerPresenter
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -125,7 +125,7 @@ class SearchAccountProviderPresenterTest {
HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
changeServerPresenter
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -160,7 +160,7 @@ class SearchAccountProviderPresenterTest {
HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
changeServerPresenter
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()

View file

@ -16,7 +16,7 @@
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.turbine.test
import com.google.common.truth.Truth.assertThat
@ -46,7 +46,7 @@ class WaitListPresenterTest {
authenticationService,
loginUserStory,
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -68,7 +68,7 @@ class WaitListPresenterTest {
authenticationService,
loginUserStory,
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -97,7 +97,7 @@ class WaitListPresenterTest {
authenticationService,
loginUserStory,
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
assertThat(loginUserStory.loginFlowIsDone.value).isFalse()

View file

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

View file

@ -16,7 +16,7 @@
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.turbine.test
import com.google.common.truth.Truth.assertThat
@ -34,7 +34,7 @@ class LogoutPreferencePresenterTest {
val presenter = DefaultLogoutPreferencePresenter(
FakeMatrixClient(),
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -47,7 +47,7 @@ class LogoutPreferencePresenterTest {
val presenter = DefaultLogoutPreferencePresenter(
FakeMatrixClient(),
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@ -65,7 +65,7 @@ class LogoutPreferencePresenterTest {
val presenter = DefaultLogoutPreferencePresenter(
matrixClient,
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()

View file

@ -78,6 +78,5 @@ dependencies {
testImplementation(projects.libraries.mediapickers.test)
testImplementation(libs.test.mockk)
androidTestImplementation(libs.test.junitext)
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.libraries.architecture.Presenter
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.coroutines.CoroutineScope
import kotlinx.coroutines.launch

View file

@ -82,6 +82,7 @@ fun ForwardMessagesView(
return
}
@Suppress("UNUSED_PARAMETER")
fun onRoomRemoved(roomSummaryDetails: RoomSummaryDetails) {
// TODO toggle selection when multi-selection is enabled
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.rememberLocalMediaViewState
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.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark

View file

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

View file

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

View file

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

View file

@ -26,12 +26,21 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
)
}
fun aMessageComposerState() = MessageComposerState(
text = "",
isFullScreen = false,
hasFocus = false,
mode = MessageComposerMode.Normal(content = ""),
showAttachmentSourcePicker = false,
attachmentsState = AttachmentsState.None,
eventSink = {}
fun aMessageComposerState(
text: String = "",
isFullScreen: Boolean = false,
hasFocus: Boolean = false,
mode: MessageComposerMode = MessageComposerMode.Normal(content = ""),
showAttachmentSourcePicker: Boolean = false,
canShareLocation: Boolean = true,
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 javax.inject.Inject
private const val backPaginationEventLimit = 20
private const val backPaginationPageSize = 50
private const val BACK_PAGINATION_EVENT_LIMIT = 20
private const val BACK_PAGINATION_PAGE_SIZE = 50
class TimelinePresenter @Inject constructor(
private val timelineItemsFactory: TimelineItemsFactory,
@ -164,6 +164,6 @@ class TimelinePresenter @Inject constructor(
}
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()
@Suppress("UNUSED_PARAMETER")
fun inReplyToClicked(eventId: EventId) {
// 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vanniktech.emoji.Emoji
import com.vanniktech.emoji.google.GoogleEmojiProvider
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@ -62,7 +61,7 @@ fun EmojiPicker(
val emojiProvider = remember { GoogleEmojiProvider() }
val categories = remember { emojiProvider.categories }
val pagerState = rememberPagerState()
Column (modifier) {
Column(modifier) {
TabRow(
selectedTabIndex = pagerState.currentPage,
) {
@ -109,7 +108,8 @@ fun EmojiPicker(
Text(
text = item.unicode,
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.PreviewParameter
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.bubble.BubbleState
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 io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Surface
private val CORNER_RADIUS = 8.dp

View file

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

View file

@ -59,7 +59,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.constraintlayout.compose.ConstrainScope
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.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
@ -291,7 +290,7 @@ private fun TimelineItemEventRowContent(
if (event.reactionsState.reactions.isNotEmpty()) {
TimelineItemReactions(
reactionsState = event.reactionsState,
mainAxisAlignment = if (event.isMine) FlowMainAxisAlignment.End else FlowMainAxisAlignment.Start,
isOutgoing = event.isMine,
onReactionClicked = onReactionClicked,
onMoreReactionsClicked = { onMoreReactionsClicked(event) },
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.outlined.AddReaction
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import 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.unit.LayoutDirection
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.timeline.aTimelineItemReactions
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.ElementPreview
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
fun TimelineItemReactions(
reactionsState: TimelineItemReactions,
mainAxisAlignment: FlowMainAxisAlignment,
isOutgoing: Boolean,
onReactionClicked: (emoji: String) -> Unit,
onMoreReactionsClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
var expanded: Boolean by rememberSaveable { mutableStateOf(false) }
val reactions by remember(reactionsState, expanded) {
derivedStateOf {
val numToDisplay = if (expanded) {
reactionsState.reactions.count()
} else {
COLLAPSE_ITEMS_THRESHOLD
}
reactionsState.reactions.take(numToDisplay).toPersistentList()
}
// In LTR languages we want an incoming message's reactions to be LRT and outgoing to be RTL.
// For RTL languages it should be the opposite.
val reactionsLayoutDirection = if (!isOutgoing) LocalLayoutDirection.current
else if (LocalLayoutDirection.current == LayoutDirection.Ltr)
LayoutDirection.Rtl
else
LayoutDirection.Ltr
CompositionLocalProvider(LocalLayoutDirection provides reactionsLayoutDirection) {
TimelineItemReactionsView(
modifier = modifier,
reactions = reactionsState.reactions,
expanded = expanded,
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
private fun TimelineItemReactionsView(
reactions: ImmutableList<AggregatedReaction>,
expandableState: ExpandableState,
mainAxisAlignment: FlowMainAxisAlignment,
expanded: Boolean,
onReactionClick: (emoji: String) -> Unit,
onMoreReactionsClick: () -> Unit,
onExpandClick: () -> Unit,
onCollapseClick: () -> Unit,
onToggleExpandClick: () -> Unit,
modifier: Modifier = Modifier
) =
FlowRow(
modifier = modifier,
mainAxisSpacing = 4.dp,
crossAxisSpacing = 4.dp,
mainAxisAlignment = mainAxisAlignment,
) {
) = TimelineItemReactionsLayout(
modifier = modifier,
itemSpacing = 4.dp,
rowSpacing = 4.dp,
expanded = expanded,
expandButton = {
MessagesReactionButton(
content = MessagesReactionsButtonContent.Text(
text = stringResource(id = if (expanded) R.string.screen_room_reactions_show_less else R.string.screen_room_reactions_show_more)
),
onClick = onToggleExpandClick,
)
},
addMoreButton = {
MessagesReactionButton(
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
onClick = onMoreReactionsClick
)
},
reactions = {
reactions.forEach { reaction ->
MessagesReactionButton(
content = MessagesReactionsButtonContent.Reaction(reaction = reaction),
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
@Composable
fun TimelineItemReactionsViewPreview() = ElementPreview {
ContentToPreview(
reactions = aTimelineItemReactions(count = 1).reactions,
expandableState = ExpandableState.None,
reactions = aTimelineItemReactions(count = 1).reactions
)
}
@DayNightPreviews
@Composable
fun TimelineItemReactionsViewCollapsedPreview() = ElementPreview {
fun TimelineItemReactionsViewFewPreview() = ElementPreview {
ContentToPreview(
reactions = aTimelineItemReactions(count = 3).reactions,
expandableState = ExpandableState.Collapsed(hidden = 7),
reactions = aTimelineItemReactions(count = 3).reactions
)
}
@DayNightPreviews
@Composable
fun TimelineItemReactionsViewExpandedPreview() = ElementPreview {
fun TimelineItemReactionsViewIncomingPreview() = ElementPreview {
ContentToPreview(
reactions = aTimelineItemReactions(count = 10).reactions,
expandableState = ExpandableState.Expanded,
reactions = aTimelineItemReactions(count = 18).reactions
)
}
@DayNightPreviews
@Composable
fun TimelineItemReactionsViewOutgoingPreview() = ElementPreview {
ContentToPreview(
reactions = aTimelineItemReactions(count = 18).reactions,
isOutgoing = true
)
}
@Composable
private fun ContentToPreview(
reactions: ImmutableList<AggregatedReaction>,
expandableState: ExpandableState
isOutgoing: Boolean = false
) {
TimelineItemReactionsView(
reactions = reactions,
expandableState = expandableState,
mainAxisAlignment = FlowMainAxisAlignment.Center,
onReactionClick = {},
onMoreReactionsClick = {},
onExpandClick = {},
onCollapseClick = {}
TimelineItemReactions(
reactionsState = TimelineItemReactions(
reactions
),
isOutgoing = isOutgoing,
onReactionClicked = {},
onMoreReactionsClicked = {},
)
}

View file

@ -65,7 +65,7 @@ fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
fun ExtraPadding.getStr(fontSize: TextUnit): String {
if (nbChars == 0) return ""
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
return " " + "\u00A0".repeat(nbOfSpaces)
}

View file

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

View file

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

View file

@ -29,7 +29,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun TimelineItemUnknownView(
content: TimelineItemUnknownContent,
@Suppress("UNUSED_PARAMETER") content: TimelineItemUnknownContent,
extraPadding: ExtraPadding,
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.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon

View file

@ -61,7 +61,7 @@ import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
private const val chipId = "chip"
private const val CHIP_ID = "chip"
@Composable
fun HtmlDocument(
@ -351,7 +351,7 @@ private fun HtmlMxReply(
Surface(
modifier = modifier
.padding(bottom = 4.dp)
.offset(x = -(8.dp)),
.offset(x = (-8).dp),
color = MaterialTheme.colorScheme.background,
shape = shape,
) {
@ -544,17 +544,27 @@ private fun AnnotatedString.Builder.appendLink(link: Element) {
pop()
}
is PermalinkData.RoomEmailInviteLink -> {
appendInlineContent(chipId, link.ownText())
safeAppendInlineContent(CHIP_ID, link.ownText())
}
is PermalinkData.RoomLink -> {
appendInlineContent(chipId, link.ownText())
safeAppendInlineContent(CHIP_ID, link.ownText())
}
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
private fun HtmlText(
text: AnnotatedString,

View file

@ -31,7 +31,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@ -165,6 +164,7 @@ internal fun RetrySendMessageMenuPreviewDark(@PreviewParameter(RetrySendMenuStat
}
}
@Suppress("UNUSED_PARAMETER")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
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.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R
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.ui.Alignment
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.PreviewParameter
import androidx.compose.ui.unit.dp

View file

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

View file

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

View file

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

View file

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

View file

@ -5,7 +5,7 @@
<item quantity="other">"%1$d changements dans la conversation"</item>
</plurals>
<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>
</plurals>
<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