Merge branch 'release/0.1.6' into main
This commit is contained in:
commit
fc40f69857
174 changed files with 3268 additions and 498 deletions
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
|
|
@ -38,7 +38,7 @@ jobs:
|
|||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.7.1
|
||||
uses: gradle/gradle-build-action@v2.8.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Assemble debug APK
|
||||
|
|
|
|||
2
.github/workflows/maestro.yml
vendored
2
.github/workflows/maestro.yml
vendored
|
|
@ -40,7 +40,7 @@ jobs:
|
|||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
- uses: mobile-dev-inc/action-maestro-cloud@v1.4.1
|
||||
- uses: mobile-dev-inc/action-maestro-cloud@v1.5.0
|
||||
with:
|
||||
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
|
||||
# Doc says (https://github.com/mobile-dev-inc/action-maestro-cloud#android):
|
||||
|
|
|
|||
2
.github/workflows/nightlyReports.yml
vendored
2
.github/workflows/nightlyReports.yml
vendored
|
|
@ -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.7.1
|
||||
uses: gradle/gradle-build-action@v2.8.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Dependency analysis
|
||||
|
|
|
|||
2
.github/workflows/quality.yml
vendored
2
.github/workflows/quality.yml
vendored
|
|
@ -40,7 +40,7 @@ jobs:
|
|||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.7.1
|
||||
uses: gradle/gradle-build-action@v2.8.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Run code quality check suite
|
||||
|
|
|
|||
2
.github/workflows/recordScreenshots.yml
vendored
2
.github/workflows/recordScreenshots.yml
vendored
|
|
@ -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.7.1
|
||||
uses: gradle/gradle-build-action@v2.8.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Record screenshots
|
||||
|
|
|
|||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.7.1
|
||||
uses: gradle/gradle-build-action@v2.8.0
|
||||
- name: Create app bundle
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
|
|
|
|||
2
.github/workflows/sonar.yml
vendored
2
.github/workflows/sonar.yml
vendored
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.7.1
|
||||
uses: gradle/gradle-build-action@v2.8.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: 🔊 Publish results to Sonar
|
||||
|
|
|
|||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.7.1
|
||||
uses: gradle/gradle-build-action@v2.8.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
|
||||
|
|
|
|||
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.9.0" />
|
||||
<option name="version" value="1.9.10" />
|
||||
</component>
|
||||
</project>
|
||||
13
.maestro/tests/roomList/timeline/messages/poll.yaml
Normal file
13
.maestro/tests/roomList/timeline/messages/poll.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
appId: ${APP_ID}
|
||||
---
|
||||
- takeScreenshot: build/maestro/530-Timeline
|
||||
- tapOn: "Add attachment"
|
||||
- tapOn: "Poll"
|
||||
- tapOn: "What is the poll about?"
|
||||
- inputText: "I am a poll"
|
||||
- tapOn: "Option 1"
|
||||
- inputText: "Answer 1"
|
||||
- tapOn: "Option 2"
|
||||
- inputText: "Answer 2"
|
||||
- tapOn: "Create"
|
||||
- takeScreenshot: build/maestro/531-Timeline
|
||||
|
|
@ -5,5 +5,6 @@ appId: ${APP_ID}
|
|||
- takeScreenshot: build/maestro/500-Timeline
|
||||
- runFlow: messages/text.yaml
|
||||
- runFlow: messages/location.yaml
|
||||
- runFlow: messages/poll.yaml
|
||||
- back
|
||||
- runFlow: ../../assertions/assertHomeDisplayed.yaml
|
||||
|
|
|
|||
23
CHANGES.md
23
CHANGES.md
|
|
@ -1,3 +1,26 @@
|
|||
Changes in Element X v0.1.6 (2023-09-04)
|
||||
========================================
|
||||
|
||||
Features ✨
|
||||
----------
|
||||
- Enable the Polls feature. Allows to create, view, vote and end polls. ([#1196](https://github.com/vector-im/element-x-android/issues/1196))
|
||||
- Create poll. ([#1143](https://github.com/vector-im/element-x-android/issues/1143))
|
||||
|
||||
Bugfixes 🐛
|
||||
----------
|
||||
- Ensure notification for Event from encrypted room get decrypted content. ([#1178](https://github.com/vector-im/element-x-android/issues/1178))
|
||||
- Make sure Snackbars are only displayed once. ([#928](https://github.com/vector-im/element-x-android/issues/928))
|
||||
- Fix the orientation of sent images. ([#1135](https://github.com/vector-im/element-x-android/issues/1135))
|
||||
- Bug reporter crashes when 'send logs' is disabled. ([#1168](https://github.com/vector-im/element-x-android/issues/1168))
|
||||
- Add missing link to the terms on the analytics setting screen. ([#1177](https://github.com/vector-im/element-x-android/issues/1177))
|
||||
- Re-enable `SyncService.withEncryptionSync` to improve decryption of notifications. ([#1198](https://github.com/vector-im/element-x-android/issues/1198))
|
||||
- Crash with `aspectRatio` modifier when `Float.NaN` was used as input. ([#1995](https://github.com/vector-im/element-x-android/issues/1995))
|
||||
|
||||
Other changes
|
||||
-------------
|
||||
- Remove unnecessary year in copyright mention. ([#1187](https://github.com/vector-im/element-x-android/issues/1187))
|
||||
|
||||
|
||||
Changes in Element X v0.1.5 (2023-08-28)
|
||||
========================================
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ If after your research you still have a question, ask at [#element-x-android:mat
|
|||
|
||||
## Copyright & License
|
||||
|
||||
Copyright (c) 2022 New Vector Ltd
|
||||
Copyright © New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this work except in compliance with the License. You may obtain a copy of the License in the [LICENSE](LICENSE) file, or at:
|
||||
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ dependencies {
|
|||
implementation(libs.network.okhttp.logging)
|
||||
implementation(libs.serialization.json)
|
||||
|
||||
implementation(libs.vanniktech.emoji)
|
||||
implementation(libs.matrix.emojibase.bindings)
|
||||
|
||||
implementation(libs.dagger)
|
||||
kapt(libs.dagger.compiler)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import io.element.android.x.di.AppComponent
|
|||
import io.element.android.x.di.DaggerAppComponent
|
||||
import io.element.android.x.info.logApplicationInfo
|
||||
import io.element.android.x.initializer.CrashInitializer
|
||||
import io.element.android.x.initializer.EmojiInitializer
|
||||
import io.element.android.x.initializer.TracingInitializer
|
||||
|
||||
class ElementXApplication : Application(), DaggerComponentOwner {
|
||||
|
|
@ -39,7 +38,6 @@ class ElementXApplication : Application(), DaggerComponentOwner {
|
|||
AppInitializer.getInstance(this).apply {
|
||||
initializeComponent(CrashInitializer::class.java)
|
||||
initializeComponent(TracingInitializer::class.java)
|
||||
initializeComponent(EmojiInitializer::class.java)
|
||||
}
|
||||
logApplicationInfo()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ import com.bumble.appyx.core.node.Node
|
|||
import com.bumble.appyx.core.node.ParentNode
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.appnav.LoggedInAppScopeFlowNode
|
||||
import io.element.android.appnav.room.RoomLoadedFlowNode
|
||||
import io.element.android.appnav.RootFlowNode
|
||||
import io.element.android.appnav.room.RoomLoadedFlowNode
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.DaggerComponentOwner
|
||||
|
|
@ -45,15 +45,14 @@ class MainNode(
|
|||
buildContext: BuildContext,
|
||||
private val mainDaggerComponentOwner: MainDaggerComponentsOwner,
|
||||
plugins: List<Plugin>,
|
||||
) :
|
||||
ParentNode<MainNode.RootNavTarget>(
|
||||
navModel = PermanentNavModel(
|
||||
navTargets = setOf(RootNavTarget),
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
) : ParentNode<MainNode.RootNavTarget>(
|
||||
navModel = PermanentNavModel(
|
||||
navTargets = setOf(RootNavTarget),
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
),
|
||||
DaggerComponentOwner by mainDaggerComponentOwner {
|
||||
|
||||
private val loggedInFlowNodeCallback = object : LoggedInAppScopeFlowNode.LifecycleCallback {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import androidx.preference.PreferenceManager
|
|||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.DefaultEmojibaseProvider
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.EmojibaseProvider
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.core.meta.BuildType
|
||||
|
|
@ -105,4 +107,10 @@ object AppModule {
|
|||
fun provideSnackbarDispatcher(): SnackbarDispatcher {
|
||||
return SnackbarDispatcher()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
fun providesEmojibaseProvider(@ApplicationContext context: Context): EmojibaseProvider {
|
||||
return DefaultEmojibaseProvider(context)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@
|
|||
package io.element.android.x.initializer
|
||||
|
||||
import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.startup.Initializer
|
||||
import io.element.android.features.preferences.impl.developer.tracing.SharedPrefTracingConfigurationStore
|
||||
import io.element.android.features.preferences.impl.developer.tracing.TargetLogLevelMapBuilder
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.matrix.api.tracing.TracingConfiguration
|
||||
import io.element.android.libraries.matrix.api.tracing.TracingFilterConfigurations
|
||||
|
|
@ -34,8 +37,11 @@ class TracingInitializer : Initializer<Unit> {
|
|||
val bugReporter = appBindings.bugReporter()
|
||||
Timber.plant(tracingService.createTimberTree())
|
||||
val tracingConfiguration = if (BuildConfig.DEBUG) {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val store = SharedPrefTracingConfigurationStore(prefs)
|
||||
val builder = TargetLogLevelMapBuilder(store)
|
||||
TracingConfiguration(
|
||||
filterConfiguration = TracingFilterConfigurations.debug,
|
||||
filterConfiguration = TracingFilterConfigurations.custom(builder.getCurrentMap()),
|
||||
writesToLogcat = true,
|
||||
writesToFilesConfiguration = WriteToFilesConfiguration.Disabled
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,16 +23,15 @@ import coil.Coil
|
|||
import com.bumble.appyx.core.composable.Children
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.node.ParentNode
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
|
|
@ -51,9 +50,9 @@ import kotlinx.parcelize.Parcelize
|
|||
class LoggedInAppScopeFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
) : BackstackNode<LoggedInAppScopeFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Root,
|
||||
) : ParentNode<LoggedInAppScopeFlowNode.NavTarget>(
|
||||
navModel = PermanentNavModel(
|
||||
navTargets = setOf(NavTarget),
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
|
|
@ -63,10 +62,8 @@ class LoggedInAppScopeFlowNode @AssistedInject constructor(
|
|||
fun onOpenBugReport()
|
||||
}
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object Root : NavTarget
|
||||
}
|
||||
@Parcelize
|
||||
object NavTarget : Parcelable
|
||||
|
||||
interface LifecycleCallback : NodeLifecycleCallback {
|
||||
fun onFlowCreated(identifier: String, client: MatrixClient)
|
||||
|
|
@ -95,31 +92,24 @@ class LoggedInAppScopeFlowNode @AssistedInject constructor(
|
|||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Root -> {
|
||||
val callback = object : LoggedInFlowNode.Callback {
|
||||
override fun onOpenBugReport() {
|
||||
plugins<Callback>().forEach { it.onOpenBugReport() }
|
||||
}
|
||||
}
|
||||
val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
|
||||
createNode<LoggedInFlowNode>(buildContext, nodeLifecycleCallbacks + callback)
|
||||
val callback = object : LoggedInFlowNode.Callback {
|
||||
override fun onOpenBugReport() {
|
||||
plugins<Callback>().forEach { it.onOpenBugReport() }
|
||||
}
|
||||
}
|
||||
val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
|
||||
return createNode<LoggedInFlowNode>(buildContext, nodeLifecycleCallbacks + callback)
|
||||
}
|
||||
|
||||
suspend fun attachSession(): LoggedInFlowNode {
|
||||
return waitForChildAttached { navTarget ->
|
||||
navTarget is NavTarget.Root
|
||||
}
|
||||
return waitForChildAttached { _ -> true }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Children(
|
||||
navModel = backstack,
|
||||
navModel = navModel,
|
||||
modifier = modifier,
|
||||
transitionHandler = rememberDefaultTransitionHandler(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ import io.element.android.libraries.di.SessionScope
|
|||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.sync.StartSyncReason
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
|
||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||
|
|
@ -124,7 +125,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
onStop = {
|
||||
//Counterpart startSync is done in observeSyncStateAndNetworkStatus method.
|
||||
coroutineScope.launch {
|
||||
syncService.stopSync()
|
||||
syncService.stopSync(StartSyncReason.AppInForeground)
|
||||
}
|
||||
},
|
||||
onDestroy = {
|
||||
|
|
@ -150,7 +151,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
.collect { (syncState, networkStatus) ->
|
||||
Timber.d("Sync state: $syncState, network status: $networkStatus")
|
||||
if (syncState != SyncState.Running && networkStatus == NetworkStatus.Online) {
|
||||
syncService.startSync()
|
||||
syncService.startSync(StartSyncReason.AppInForeground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import org.jetbrains.kotlin.cli.common.toBooleanLenient
|
|||
|
||||
buildscript {
|
||||
dependencies {
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.0")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.10")
|
||||
classpath("com.google.gms:google-services:4.3.15")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
64
docs/install_from_github_release.md
Normal file
64
docs/install_from_github_release.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Installing Element X Android from a Github Release
|
||||
|
||||
This document explains how to install Element X Android from a Github Release.
|
||||
|
||||
<!--- TOC -->
|
||||
|
||||
* [Requirements](#requirements)
|
||||
* [Steps](#steps)
|
||||
* [I already have the application on my phone](#i-already-have-the-application-on-my-phone)
|
||||
|
||||
<!--- END -->
|
||||
|
||||
## Requirements
|
||||
|
||||
The Github release will contain an Android App Bundle (with `aab` extension) file, unlike in the Element Android project where releases directly provide the APKs. So there are some steps to perform to generate and sign App Bundle APKs. An APK suitable for the targeted device will then be generated.
|
||||
|
||||
The easiest way to do that is to use the debug signature that is shared between the developers and stored in the Element X Android project. So we recommend to clone the project first, to be able to use the debug signature it contains. But note that you can use any other signature. You don't need to install Android Studio, you will only need a shell terminal.
|
||||
|
||||
You can clone the project by running:
|
||||
```bash
|
||||
git clone git@github.com:vector-im/element-x-android.git
|
||||
```
|
||||
or
|
||||
```bash
|
||||
git clone https://github.com/vector-im/element-x-android.git
|
||||
```
|
||||
|
||||
You will also need to install [bundletool](https://developer.android.com/studio/command-line/bundletool). On MacOS, you can run the following command:
|
||||
|
||||
```bash
|
||||
brew install bundletool
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
1. Open the GitHub release that you want to install from https://github.com/vector-im/element-x-android/releases
|
||||
2. Download the asset `app-release-signed.aab`
|
||||
3. Navigate to the folder where you cloned the project and run the following command:
|
||||
```bash
|
||||
bundletool build-apks --bundle=<PATH_TO_YOUR_APP-RELEASE-SIGNED.AAB_FILE> --output=./tmp/elementx.apks \
|
||||
--ks=./app/signature/debug.keystore --ks-pass=pass:android --ks-key-alias=androiddebugkey --key-pass=pass:android \
|
||||
--overwrite
|
||||
```
|
||||
For instance:
|
||||
```bash
|
||||
bundletool build-apks --bundle=./tmp/Element/0.1.5/app-release-signed.aab --output=./tmp/elementx.apks \
|
||||
--ks=./app/signature/debug.keystore --ks-pass=pass:android --ks-key-alias=androiddebugkey --key-pass=pass:android \
|
||||
--overwrite
|
||||
```
|
||||
4. Run an Android emulator, or connect a real device to your computer
|
||||
5. Install the APKs on the device:
|
||||
```bash
|
||||
bundletool install-apks --apks=./tmp/elementx.apks
|
||||
```
|
||||
|
||||
That's it, the application should be installed on your device, you can start it from the launcher icon.
|
||||
|
||||
## I already have the application on my phone
|
||||
|
||||
If the application was already installed on your phone, there are several cases:
|
||||
|
||||
- it was installed from the PlayStore, you will have to uninstall it first because the signature will not match.
|
||||
- it was installed from a previous GitHub release, this is like an application upgrade, so no need to uninstall the existing app.
|
||||
- it was installed from a more recent GitHub release, you will have to uninstall it first.
|
||||
2
fastlane/metadata/android/en-US/changelogs/40001060.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/40001060.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
Main changes in this version: bugfixes.
|
||||
Full changelog: https://github.com/vector-im/element-x-android/releases
|
||||
|
|
@ -21,5 +21,6 @@ import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
|||
data class AnalyticsPreferencesState(
|
||||
val applicationName: String,
|
||||
val isEnabled: Boolean,
|
||||
val policyUrl: String,
|
||||
val eventSink: (AnalyticsOptInEvents) -> Unit,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -28,5 +28,6 @@ open class AnalyticsPreferencesStateProvider : PreviewParameterProvider<Analytic
|
|||
fun aAnalyticsPreferencesState() = AnalyticsPreferencesState(
|
||||
applicationName = "Element X",
|
||||
isEnabled = false,
|
||||
policyUrl = "https://element.io",
|
||||
eventSink = {}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,22 +16,21 @@
|
|||
|
||||
package io.element.android.features.analytics.api.preferences
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
|
||||
import io.element.android.libraries.designsystem.components.LINK_TAG
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.theme.LinkColor
|
||||
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
|
|
@ -43,40 +42,33 @@ fun AnalyticsPreferencesView(
|
|||
state.eventSink(AnalyticsOptInEvents.EnableAnalytics(isEnabled = isEnabled))
|
||||
}
|
||||
|
||||
val firstPart = stringResource(id = CommonStrings.screen_analytics_settings_help_us_improve, state.applicationName)
|
||||
val secondPart = buildAnnotatedStringWithColoredPart(
|
||||
val supportingText = stringResource(
|
||||
id = CommonStrings.screen_analytics_settings_help_us_improve,
|
||||
state.applicationName
|
||||
)
|
||||
val linkText = buildAnnotatedStringWithStyledPart(
|
||||
CommonStrings.screen_analytics_settings_read_terms,
|
||||
CommonStrings.screen_analytics_settings_read_terms_content_link
|
||||
)
|
||||
val subtitle = "$firstPart\n\n$secondPart"
|
||||
|
||||
PreferenceSwitch(
|
||||
modifier = modifier,
|
||||
title = stringResource(id = CommonStrings.screen_analytics_settings_share_data),
|
||||
subtitle = subtitle,
|
||||
isChecked = state.isEnabled,
|
||||
onCheckedChange = ::onEnabledChanged,
|
||||
switchAlignment = Alignment.Top,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun buildAnnotatedStringWithColoredPart(
|
||||
@StringRes fullTextRes: Int,
|
||||
@StringRes coloredTextRes: Int,
|
||||
color: Color = LinkColor,
|
||||
underline: Boolean = true,
|
||||
) = buildAnnotatedString {
|
||||
val coloredPart = stringResource(coloredTextRes)
|
||||
val fullText = stringResource(fullTextRes, coloredPart)
|
||||
val startIndex = fullText.indexOf(coloredPart)
|
||||
append(fullText)
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
color = color,
|
||||
textDecoration = if (underline) TextDecoration.Underline else null
|
||||
), start = startIndex, end = startIndex + coloredPart.length
|
||||
CommonStrings.screen_analytics_settings_read_terms_content_link,
|
||||
tagAndLink = LINK_TAG to state.policyUrl,
|
||||
)
|
||||
Column(modifier) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(stringResource(id = CommonStrings.screen_analytics_settings_share_data))
|
||||
},
|
||||
supportingContent = {
|
||||
Text(supportingText)
|
||||
},
|
||||
leadingContent = null,
|
||||
trailingContent = ListItemContent.Switch(
|
||||
checked = state.isEnabled,
|
||||
),
|
||||
onClick = {
|
||||
onEnabledChanged(!state.isEnabled)
|
||||
}
|
||||
)
|
||||
ListSupportingText(annotatedString = linkText)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
|
@ -91,5 +83,7 @@ internal fun AnalyticsPreferencesViewDarkPreview(@PreviewParameter(AnalyticsPref
|
|||
|
||||
@Composable
|
||||
private fun ContentToPreview(state: AnalyticsPreferencesState) {
|
||||
AnalyticsPreferencesView(state)
|
||||
AnalyticsPreferencesView(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ package io.element.android.features.analytics.impl
|
|||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
|
@ -28,7 +27,7 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Poll
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
|
|
@ -37,7 +36,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.BiasAlignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
|
@ -45,6 +43,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
||||
import io.element.android.features.analytics.api.Config
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem
|
||||
|
|
@ -56,7 +55,6 @@ import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithSt
|
|||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.ButtonSize
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
|
||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||
|
|
@ -98,6 +96,8 @@ fun AnalyticsOptInView(
|
|||
)
|
||||
}
|
||||
|
||||
private const val LINK_TAG = "link"
|
||||
|
||||
@Composable
|
||||
private fun AnalyticsOptInHeader(
|
||||
state: AnalyticsOptInState,
|
||||
|
|
@ -114,21 +114,29 @@ private fun AnalyticsOptInHeader(
|
|||
subTitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
|
||||
iconImageVector = Icons.Filled.Poll
|
||||
)
|
||||
Text(
|
||||
text = buildAnnotatedStringWithStyledPart(
|
||||
R.string.screen_analytics_prompt_read_terms,
|
||||
R.string.screen_analytics_prompt_read_terms_content_link,
|
||||
color = Color.Unspecified,
|
||||
underline = false,
|
||||
bold = true,
|
||||
),
|
||||
val text = buildAnnotatedStringWithStyledPart(
|
||||
R.string.screen_analytics_prompt_read_terms,
|
||||
R.string.screen_analytics_prompt_read_terms_content_link,
|
||||
color = Color.Unspecified,
|
||||
underline = false,
|
||||
bold = true,
|
||||
tagAndLink = LINK_TAG to Config.POLICY_LINK,
|
||||
)
|
||||
ClickableText(
|
||||
text = text,
|
||||
onClick = {
|
||||
text
|
||||
.getStringAnnotations(LINK_TAG, it, it)
|
||||
.firstOrNull()
|
||||
?.let { _ -> onClickTerms() }
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(shape = RoundedCornerShape(8.dp))
|
||||
.clickable { onClickTerms() }
|
||||
.padding(8.dp),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
style = ElementTheme.typography.fontBodyMdRegular
|
||||
.copy(
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.compose.runtime.collectAsState
|
|||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
||||
import io.element.android.features.analytics.api.Config
|
||||
import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesPresenter
|
||||
import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesState
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
|
|
@ -51,6 +52,7 @@ class DefaultAnalyticsPreferencesPresenter @Inject constructor(
|
|||
return AnalyticsPreferencesState(
|
||||
applicationName = buildMeta.applicationName,
|
||||
isEnabled = isEnabled.value,
|
||||
policyUrl = Config.POLICY_LINK,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class AnalyticsPreferencesPresenterTest {
|
|||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.isEnabled).isTrue()
|
||||
assertThat(initialState.policyUrl).isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ plugins {
|
|||
alias(libs.plugins.anvil)
|
||||
alias(libs.plugins.ksp)
|
||||
id("kotlin-parcelize")
|
||||
kotlin("plugin.serialization") version "1.9.0"
|
||||
kotlin("plugin.serialization") version "1.9.10"
|
||||
}
|
||||
|
||||
android {
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ dependencies {
|
|||
implementation(libs.accompanist.systemui)
|
||||
implementation(libs.vanniktech.blurhash)
|
||||
implementation(libs.telephoto.zoomableimage)
|
||||
implementation(libs.vanniktech.emoji)
|
||||
implementation(libs.matrix.emojibase.bindings)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.poll.api.create.CreatePollEntryPoint
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
|
|
@ -64,6 +65,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
@Assisted plugins: List<Plugin>,
|
||||
private val sendLocationEntryPoint: SendLocationEntryPoint,
|
||||
private val showLocationEntryPoint: ShowLocationEntryPoint,
|
||||
private val createPollEntryPoint: CreatePollEntryPoint,
|
||||
) : BackstackNode<MessagesFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Messages,
|
||||
|
|
@ -101,6 +103,9 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
|
||||
@Parcelize
|
||||
data object SendLocation : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object CreatePoll : NavTarget
|
||||
}
|
||||
|
||||
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
|
||||
|
|
@ -140,6 +145,10 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
override fun onSendLocationClicked() {
|
||||
backstack.push(NavTarget.SendLocation)
|
||||
}
|
||||
|
||||
override fun onCreatePollClicked() {
|
||||
backstack.push(NavTarget.CreatePoll)
|
||||
}
|
||||
}
|
||||
createNode<MessagesNode>(buildContext, listOf(callback))
|
||||
}
|
||||
|
|
@ -179,6 +188,9 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
NavTarget.SendLocation -> {
|
||||
sendLocationEntryPoint.createNode(this, buildContext)
|
||||
}
|
||||
NavTarget.CreatePoll -> {
|
||||
createPollEntryPoint.createNode(this, buildContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ class MessagesNode @AssistedInject constructor(
|
|||
fun onForwardEventClicked(eventId: EventId)
|
||||
fun onReportMessage(eventId: EventId, senderId: UserId)
|
||||
fun onSendLocationClicked()
|
||||
fun onCreatePollClicked()
|
||||
}
|
||||
|
||||
init {
|
||||
|
|
@ -99,6 +100,10 @@ class MessagesNode @AssistedInject constructor(
|
|||
callback?.onSendLocationClicked()
|
||||
}
|
||||
|
||||
private fun onCreatePollClicked() {
|
||||
callback?.onCreatePollClicked()
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
|
|
@ -110,6 +115,7 @@ class MessagesNode @AssistedInject constructor(
|
|||
onPreviewAttachments = this::onPreviewAttachments,
|
||||
onUserDataClicked = this::onUserDataClicked,
|
||||
onSendLocationClicked = this::onSendLocationClicked,
|
||||
onCreatePollClicked = this::onCreatePollClicked,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
TimelineItemAction.Developer -> handleShowDebugInfoAction(targetEvent)
|
||||
TimelineItemAction.Forward -> handleForwardAction(targetEvent)
|
||||
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
|
||||
TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -310,6 +311,11 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
navigator.onReportContentClicked(event.eventId, event.senderId)
|
||||
}
|
||||
|
||||
private suspend fun handleEndPollAction(event: TimelineItem.Event) {
|
||||
event.eventId?.let { room.endPoll(it, "The poll with event id: $it has ended.") }
|
||||
// TODO Polls: Send poll end analytic
|
||||
}
|
||||
|
||||
private suspend fun handleCopyContents(event: TimelineItem.Event) {
|
||||
val content = when (event.content) {
|
||||
is TimelineItemTextBasedContent -> event.content.body
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ fun aMessagesState() = MessagesState(
|
|||
),
|
||||
actionListState = anActionListState(),
|
||||
customReactionState = CustomReactionState(
|
||||
selectedEventId = null,
|
||||
target = CustomReactionState.Target.None,
|
||||
eventSink = {},
|
||||
selectedEmoji = persistentSetOf(),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ fun MessagesView(
|
|||
onUserDataClicked: (UserId) -> Unit,
|
||||
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
|
||||
onSendLocationClicked: () -> Unit,
|
||||
onCreatePollClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
||||
|
|
@ -140,7 +141,7 @@ fun MessagesView(
|
|||
}
|
||||
|
||||
fun onMoreReactionsClicked(event: TimelineItem.Event) {
|
||||
state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event))
|
||||
state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
|
|
@ -175,6 +176,7 @@ fun MessagesView(
|
|||
onReactionLongClicked = ::onEmojiReactionLongClicked,
|
||||
onMoreReactionsClicked = ::onMoreReactionsClicked,
|
||||
onSendLocationClicked = onSendLocationClicked,
|
||||
onCreatePollClicked = onCreatePollClicked,
|
||||
onSwipeToReply = { targetEvent ->
|
||||
state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, targetEvent))
|
||||
},
|
||||
|
|
@ -192,18 +194,17 @@ fun MessagesView(
|
|||
state = state.actionListState,
|
||||
onActionSelected = ::onActionSelected,
|
||||
onCustomReactionClicked = { event ->
|
||||
state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event))
|
||||
if (event.eventId == null) return@ActionListView
|
||||
state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
|
||||
},
|
||||
onEmojiReactionClicked = ::onEmojiReactionClicked,
|
||||
)
|
||||
|
||||
CustomReactionBottomSheet(
|
||||
state = state.customReactionState,
|
||||
onEmojiSelected = { emoji ->
|
||||
state.customReactionState.selectedEventId?.let { eventId ->
|
||||
onEmojiSelected = { eventId, emoji ->
|
||||
state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId))
|
||||
state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(null))
|
||||
}
|
||||
state.customReactionState.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -267,6 +268,7 @@ private fun MessagesViewContent(
|
|||
onMessageLongClicked: (TimelineItem.Event) -> Unit,
|
||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||
onSendLocationClicked: () -> Unit,
|
||||
onCreatePollClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||
) {
|
||||
|
|
@ -295,6 +297,7 @@ private fun MessagesViewContent(
|
|||
MessageComposerView(
|
||||
state = state.composerState,
|
||||
onSendLocationClicked = onSendLocationClicked,
|
||||
onCreatePollClicked = onCreatePollClicked,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(Alignment.Bottom)
|
||||
|
|
@ -401,5 +404,6 @@ private fun ContentToPreview(state: MessagesState) {
|
|||
onPreviewAttachments = {},
|
||||
onUserDataClicked = {},
|
||||
onSendLocationClicked = {},
|
||||
onCreatePollClicked = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
|
||||
|
|
@ -96,6 +97,32 @@ class ActionListPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
is TimelineItemPollContent -> {
|
||||
buildList {
|
||||
val isMineOrCanRedact = timelineItem.isMine || userCanRedact
|
||||
|
||||
// TODO Poll: Reply to poll
|
||||
// if (timelineItem.isRemote) {
|
||||
// // Can only reply or forward messages already uploaded to the server
|
||||
// add(TimelineItemAction.Reply)
|
||||
// }
|
||||
if (!timelineItem.content.isEnded && timelineItem.isRemote && isMineOrCanRedact) {
|
||||
add(TimelineItemAction.EndPoll)
|
||||
}
|
||||
if (timelineItem.content.canBeCopied()) {
|
||||
add(TimelineItemAction.Copy)
|
||||
}
|
||||
if (buildMeta.isDebuggable) {
|
||||
add(TimelineItemAction.Developer)
|
||||
}
|
||||
if (!timelineItem.isMine) {
|
||||
add(TimelineItemAction.ReportContent)
|
||||
}
|
||||
if (isMineOrCanRedact) {
|
||||
add(TimelineItemAction.Redact)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> buildList<TimelineItemAction> {
|
||||
if (timelineItem.isRemote) {
|
||||
// Can only reply or forward messages already uploaded to the server
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
|||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemLocationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -83,6 +84,15 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
),
|
||||
displayEmojiReactions = false,
|
||||
),
|
||||
anActionListState().copy(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(content = aTimelineItemPollContent()).copy(
|
||||
reactionsState = reactionsState
|
||||
),
|
||||
actions = aTimelineItemPollActionList(),
|
||||
),
|
||||
displayEmojiReactions = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -104,3 +114,13 @@ fun aTimelineItemActionList(): ImmutableList<TimelineItemAction> {
|
|||
TimelineItemAction.Developer,
|
||||
)
|
||||
}
|
||||
fun aTimelineItemPollActionList(): ImmutableList<TimelineItemAction> {
|
||||
return persistentListOf(
|
||||
TimelineItemAction.EndPoll,
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.Developer,
|
||||
TimelineItemAction.ReportContent,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,4 +35,5 @@ sealed class TimelineItemAction(
|
|||
data object Edit : TimelineItemAction(CommonStrings.action_edit, VectorIcons.Edit)
|
||||
data object Developer : TimelineItemAction(CommonStrings.action_view_source, VectorIcons.DeveloperMode)
|
||||
data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, VectorIcons.ReportContent, destructive = true)
|
||||
data object EndPoll : TimelineItemAction(CommonStrings.action_end_poll, VectorIcons.EndPoll)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import androidx.compose.material.ExperimentalMaterialApi
|
|||
import androidx.compose.material.ListItem
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.BarChart
|
||||
import androidx.compose.material.icons.filled.Collections
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.PhotoCamera
|
||||
|
|
@ -52,6 +53,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
|||
internal fun AttachmentsBottomSheet(
|
||||
state: MessageComposerState,
|
||||
onSendLocationClicked: () -> Unit,
|
||||
onCreatePollClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val localView = LocalView.current
|
||||
|
|
@ -85,6 +87,7 @@ internal fun AttachmentsBottomSheet(
|
|||
AttachmentSourcePickerMenu(
|
||||
state = state,
|
||||
onSendLocationClicked = onSendLocationClicked,
|
||||
onCreatePollClicked = onCreatePollClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -95,6 +98,7 @@ internal fun AttachmentsBottomSheet(
|
|||
internal fun AttachmentSourcePickerMenu(
|
||||
state: MessageComposerState,
|
||||
onSendLocationClicked: () -> Unit,
|
||||
onCreatePollClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
|
|
@ -131,6 +135,16 @@ internal fun AttachmentSourcePickerMenu(
|
|||
text = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
|
||||
)
|
||||
}
|
||||
if (state.canCreatePoll) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
state.eventSink(MessageComposerEvents.PickAttachmentSource.Poll)
|
||||
onCreatePollClicked()
|
||||
},
|
||||
icon = { Icon(Icons.Default.BarChart, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,5 +156,6 @@ internal fun AttachmentSourcePickerMenuPreview() = ElementPreview {
|
|||
canShareLocation = true,
|
||||
),
|
||||
onSendLocationClicked = {},
|
||||
onCreatePollClicked = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ sealed interface MessageComposerEvents {
|
|||
data object PhotoFromCamera : PickAttachmentSource
|
||||
data object VideoFromCamera : PickAttachmentSource
|
||||
data object Location : PickAttachmentSource
|
||||
data object Poll : PickAttachmentSource
|
||||
}
|
||||
data object CancelSendAttachment : MessageComposerEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,11 @@ class MessageComposerPresenter @Inject constructor(
|
|||
canShareLocation.value = featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)
|
||||
}
|
||||
|
||||
val canCreatePoll = remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
canCreatePoll.value = featureFlagService.isFeatureEnabled(FeatureFlags.Polls)
|
||||
}
|
||||
|
||||
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
|
||||
handlePickedMedia(attachmentsState, uri, mimeType)
|
||||
}
|
||||
|
|
@ -179,6 +184,10 @@ class MessageComposerPresenter @Inject constructor(
|
|||
showAttachmentSourcePicker = false
|
||||
// Navigation to the location picker screen is done at the view layer
|
||||
}
|
||||
MessageComposerEvents.PickAttachmentSource.Poll -> {
|
||||
showAttachmentSourcePicker = false
|
||||
// Navigation to the create poll screen is done at the view layer
|
||||
}
|
||||
is MessageComposerEvents.CancelSendAttachment -> {
|
||||
ongoingSendAttachmentJob.value?.let {
|
||||
it.cancel()
|
||||
|
|
@ -195,6 +204,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
mode = messageComposerContext.composerMode,
|
||||
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||
canShareLocation = canShareLocation.value,
|
||||
canCreatePoll = canCreatePoll.value,
|
||||
attachmentsState = attachmentsState.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ data class MessageComposerState(
|
|||
val mode: MessageComposerMode,
|
||||
val showAttachmentSourcePicker: Boolean,
|
||||
val canShareLocation: Boolean,
|
||||
val canCreatePoll: Boolean,
|
||||
val attachmentsState: AttachmentsState,
|
||||
val eventSink: (MessageComposerEvents) -> Unit
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ fun aMessageComposerState(
|
|||
mode: MessageComposerMode = MessageComposerMode.Normal(content = ""),
|
||||
showAttachmentSourcePicker: Boolean = false,
|
||||
canShareLocation: Boolean = true,
|
||||
canCreatePoll: Boolean = true,
|
||||
attachmentsState: AttachmentsState = AttachmentsState.None,
|
||||
) = MessageComposerState(
|
||||
text = text,
|
||||
|
|
@ -41,6 +42,7 @@ fun aMessageComposerState(
|
|||
mode = mode,
|
||||
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||
canShareLocation = canShareLocation,
|
||||
canCreatePoll = canCreatePoll,
|
||||
attachmentsState = attachmentsState,
|
||||
eventSink = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import io.element.android.libraries.textcomposer.TextComposer
|
|||
fun MessageComposerView(
|
||||
state: MessageComposerState,
|
||||
onSendLocationClicked: () -> Unit,
|
||||
onCreatePollClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun onFullscreenToggle() {
|
||||
|
|
@ -59,6 +60,7 @@ fun MessageComposerView(
|
|||
AttachmentsBottomSheet(
|
||||
state = state,
|
||||
onSendLocationClicked = onSendLocationClicked,
|
||||
onCreatePollClicked = onCreatePollClicked,
|
||||
)
|
||||
|
||||
TextComposer(
|
||||
|
|
@ -88,6 +90,7 @@ internal fun MessageComposerViewDarkPreview(@PreviewParameter(MessageComposerSta
|
|||
private fun ContentToPreview(state: MessageComposerState) {
|
||||
MessageComposerView(
|
||||
state = state,
|
||||
onSendLocationClicked = {}
|
||||
onSendLocationClicked = {},
|
||||
onCreatePollClicked = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,4 +22,8 @@ sealed interface TimelineEvents {
|
|||
data object LoadMore : TimelineEvents
|
||||
data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents
|
||||
data class OnScrollFinished(val firstIndex: Int) : TimelineEvents
|
||||
data class PollAnswerSelected(
|
||||
val pollStartId: EventId,
|
||||
val answerId: String
|
||||
) : TimelineEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,13 @@ class TimelinePresenter @Inject constructor(
|
|||
lastReadReceiptId = lastReadReceiptId
|
||||
)
|
||||
}
|
||||
is TimelineEvents.PollAnswerSelected -> appScope.launch {
|
||||
room.sendPollResponse(
|
||||
pollStartId = event.pollStartId,
|
||||
answers = listOf(event.answerId),
|
||||
)
|
||||
// TODO Polls: Send poll vote analytic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,9 @@ fun TimelineView(
|
|||
// TODO implement this logic once we have support to 'jump to event X' in sliding sync
|
||||
}
|
||||
|
||||
fun onPollAnswerSelected(pollStartId: EventId, answerId: String) {
|
||||
state.eventSink(TimelineEvents.PollAnswerSelected(pollStartId, answerId))
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
LazyColumn(
|
||||
|
|
@ -125,6 +128,7 @@ fun TimelineView(
|
|||
onReactionLongClick = onReactionLongClicked,
|
||||
onMoreReactionsClick = onMoreReactionsClicked,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onPollAnswerSelected = ::onPollAnswerSelected,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
)
|
||||
}
|
||||
|
|
@ -162,6 +166,7 @@ fun TimelineItemRow(
|
|||
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
|
||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||
onPollAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
when (timelineItem) {
|
||||
|
|
@ -194,6 +199,7 @@ fun TimelineItemRow(
|
|||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
||||
onPollAnswerSelected = onPollAnswerSelected,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -231,6 +237,7 @@ fun TimelineItemRow(
|
|||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onPollAnswerSelected = onPollAnswerSelected,
|
||||
onSwipeToReply = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ fun TimelineItemEventRow(
|
|||
onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
|
||||
onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit,
|
||||
onSwipeToReply: () -> Unit,
|
||||
onPollAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
|
@ -175,6 +176,7 @@ fun TimelineItemEventRow(
|
|||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
onPollAnswerSelected = onPollAnswerSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -191,6 +193,7 @@ fun TimelineItemEventRow(
|
|||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
onPollAnswerSelected = onPollAnswerSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -232,6 +235,7 @@ private fun TimelineItemEventRowContent(
|
|||
onReactionClicked: (emoji: String) -> Unit,
|
||||
onReactionLongClicked: (emoji: String) -> Unit,
|
||||
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
|
||||
onPollAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun ConstrainScope.linkStartOrEnd(event: TimelineItem.Event) = if (event.isMine) {
|
||||
|
|
@ -289,7 +293,8 @@ private fun TimelineItemEventRowContent(
|
|||
inReplyToClick = inReplyToClicked,
|
||||
onTimestampClicked = {
|
||||
onTimestampClicked(event)
|
||||
}
|
||||
},
|
||||
onPollAnswerSelected = onPollAnswerSelected,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -360,6 +365,7 @@ private fun MessageEventBubbleContent(
|
|||
onMessageLongClick: () -> Unit,
|
||||
inReplyToClick: () -> Unit,
|
||||
onTimestampClicked: () -> Unit,
|
||||
onPollAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
|
||||
@SuppressLint("ModifierParameter") bubbleModifier: Modifier = Modifier, // need to rename this modifier to distinguish it from the following ones
|
||||
) {
|
||||
val timestampPosition = when (event.content) {
|
||||
|
|
@ -385,6 +391,7 @@ private fun MessageEventBubbleContent(
|
|||
onClick = onMessageClick,
|
||||
onLongClick = onMessageLongClick,
|
||||
extraPadding = event.toExtraPadding(),
|
||||
onPollAnswerSelected = onPollAnswerSelected,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -607,6 +614,7 @@ private fun ContentToPreview() {
|
|||
onMoreReactionsClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
onPollAnswerSelected = { _, _ -> },
|
||||
)
|
||||
TimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
|
|
@ -627,6 +635,7 @@ private fun ContentToPreview() {
|
|||
onMoreReactionsClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
onPollAnswerSelected = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -674,6 +683,7 @@ private fun ContentToPreviewWithReply() {
|
|||
onMoreReactionsClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
onPollAnswerSelected = { _, _ -> },
|
||||
)
|
||||
TimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
|
|
@ -695,6 +705,7 @@ private fun ContentToPreviewWithReply() {
|
|||
onMoreReactionsClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
onPollAnswerSelected = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -752,6 +763,7 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) {
|
|||
onMoreReactionsClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
onPollAnswerSelected = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -792,6 +804,7 @@ private fun ContentWithManyReactionsToPreview() {
|
|||
onMoreReactionsClick = {},
|
||||
onSwipeToReply = {},
|
||||
onTimestampClicked = {},
|
||||
onPollAnswerSelected = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -816,6 +829,7 @@ internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight {
|
|||
onMoreReactionsClick = {},
|
||||
onSwipeToReply = {},
|
||||
onTimestampClicked = {},
|
||||
onPollAnswerSelected = { _, _ -> },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -836,5 +850,6 @@ internal fun TimelineItemEventTimestampBelowPreview() = ElementPreviewLight {
|
|||
onMoreReactionsClick = {},
|
||||
onSwipeToReply = {},
|
||||
onTimestampClicked = {},
|
||||
onPollAnswerSelected = { _, _ -> },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ fun TimelineItemStateEventRow(
|
|||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
extraPadding = noExtraPadding,
|
||||
onPollAnswerSelected = { _, _ -> error("Polls are not supported in state events") },
|
||||
modifier = Modifier.defaultTimelineContentPadding()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,34 +22,35 @@ import androidx.compose.material3.rememberModalBottomSheetState
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.vanniktech.emoji.Emoji
|
||||
import io.element.android.features.messages.impl.timeline.components.EmojiPicker
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||
import io.element.android.libraries.designsystem.theme.components.hide
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CustomReactionBottomSheet(
|
||||
state: CustomReactionState,
|
||||
onEmojiSelected: (Emoji) -> Unit,
|
||||
onEmojiSelected: (EventId, Emoji) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val target = state.target as? CustomReactionState.Target.Success
|
||||
|
||||
fun onDismiss() {
|
||||
state.eventSink(CustomReactionEvents.UpdateSelectedEvent(null))
|
||||
state.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
|
||||
}
|
||||
|
||||
fun onEmojiSelectedDismiss(emoji: Emoji) {
|
||||
if (target?.event?.eventId == null) return
|
||||
sheetState.hide(coroutineScope) {
|
||||
state.eventSink(CustomReactionEvents.UpdateSelectedEvent(null))
|
||||
onEmojiSelected(emoji)
|
||||
state.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
|
||||
onEmojiSelected(target.event.eventId, emoji)
|
||||
}
|
||||
}
|
||||
|
||||
val isVisible = state.selectedEventId != null
|
||||
if (isVisible) {
|
||||
if (target?.emojibaseStore != null && target.event.eventId != null) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = ::onDismiss,
|
||||
sheetState = sheetState,
|
||||
|
|
@ -57,8 +58,9 @@ fun CustomReactionBottomSheet(
|
|||
) {
|
||||
EmojiPicker(
|
||||
onEmojiSelected = ::onEmojiSelectedDismiss,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
emojibaseStore = target.emojibaseStore,
|
||||
selectedEmojis = state.selectedEmoji,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@ package io.element.android.features.messages.impl.timeline.components.customreac
|
|||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
|
||||
sealed interface CustomReactionEvents {
|
||||
data class UpdateSelectedEvent(val event: TimelineItem.Event?) : CustomReactionEvents
|
||||
data class ShowCustomReactionSheet(val event: TimelineItem.Event) : CustomReactionEvents
|
||||
object DismissCustomReactionSheet : CustomReactionEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,28 +17,53 @@
|
|||
package io.element.android.features.messages.impl.timeline.components.customreaction
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import kotlinx.coroutines.launch
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import javax.inject.Inject
|
||||
|
||||
class CustomReactionPresenter @Inject constructor() : Presenter<CustomReactionState> {
|
||||
class CustomReactionPresenter @Inject constructor(
|
||||
private val emojibaseProvider: EmojibaseProvider
|
||||
) : Presenter<CustomReactionState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): CustomReactionState {
|
||||
var selectedEvent by remember { mutableStateOf<TimelineItem.Event?>(null) }
|
||||
val target: MutableState<CustomReactionState.Target> = remember {
|
||||
mutableStateOf(CustomReactionState.Target.None)
|
||||
}
|
||||
|
||||
fun handleEvents(event: CustomReactionEvents) {
|
||||
when (event) {
|
||||
is CustomReactionEvents.UpdateSelectedEvent -> selectedEvent = event.event
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
fun handleShowCustomReactionSheet(event: TimelineItem.Event) {
|
||||
target.value = CustomReactionState.Target.Loading(event)
|
||||
localCoroutineScope.launch {
|
||||
target.value = CustomReactionState.Target.Success(
|
||||
event = event,
|
||||
emojibaseStore = emojibaseProvider.emojibaseStore
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val selectedEmoji = selectedEvent?.reactionsState?.reactions?.mapNotNull { if(it.isHighlighted) it.key else null }.orEmpty().toImmutableSet()
|
||||
return CustomReactionState(selectedEventId = selectedEvent?.eventId, selectedEmoji = selectedEmoji, eventSink = ::handleEvents)
|
||||
fun handleDismissCustomReactionSheet() {
|
||||
target.value = CustomReactionState.Target.None
|
||||
}
|
||||
|
||||
fun handleEvents(event: CustomReactionEvents) {
|
||||
when (event) {
|
||||
is CustomReactionEvents.ShowCustomReactionSheet -> handleShowCustomReactionSheet(event.event)
|
||||
is CustomReactionEvents.DismissCustomReactionSheet -> handleDismissCustomReactionSheet()
|
||||
}
|
||||
}
|
||||
val event = (target.value as? CustomReactionState.Target.Success)?.event
|
||||
val selectedEmoji = event?.reactionsState?.reactions?.mapNotNull { if(it.isHighlighted) it.key else null }.orEmpty().toImmutableSet()
|
||||
return CustomReactionState(
|
||||
target = target.value,
|
||||
selectedEmoji = selectedEmoji,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,23 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components.customreaction
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.emojibasebindings.EmojibaseStore
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
|
||||
data class CustomReactionState(
|
||||
val selectedEventId: EventId?,
|
||||
val target: Target,
|
||||
val selectedEmoji: ImmutableSet<String>,
|
||||
val eventSink: (CustomReactionEvents) -> Unit,
|
||||
)
|
||||
) {
|
||||
sealed interface Target {
|
||||
|
||||
data object None : Target
|
||||
data class Loading(val event: TimelineItem.Event) : Target
|
||||
data class Success(
|
||||
val event: TimelineItem.Event,
|
||||
val emojibaseStore: EmojibaseStore,
|
||||
) : Target
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,16 +14,16 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.initializer
|
||||
package io.element.android.features.messages.impl.timeline.components.customreaction
|
||||
|
||||
import androidx.startup.Initializer
|
||||
import com.vanniktech.emoji.EmojiManager
|
||||
import com.vanniktech.emoji.google.GoogleEmojiProvider
|
||||
import android.content.Context
|
||||
import io.element.android.emojibasebindings.EmojibaseDatasource
|
||||
import io.element.android.emojibasebindings.EmojibaseStore
|
||||
|
||||
class EmojiInitializer : Initializer<Unit> {
|
||||
override fun create(context: android.content.Context) {
|
||||
EmojiManager.install(GoogleEmojiProvider())
|
||||
class DefaultEmojibaseProvider(val context: Context): EmojibaseProvider {
|
||||
|
||||
override val emojibaseStore: EmojibaseStore by lazy {
|
||||
EmojibaseDatasource().load(context)
|
||||
}
|
||||
|
||||
override fun dependencies(): MutableList<Class<out Initializer<*>>> = mutableListOf()
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
package io.element.android.features.messages.impl.timeline.components.customreaction
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
|
|
@ -41,11 +41,15 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vanniktech.emoji.Emoji
|
||||
import com.vanniktech.emoji.google.GoogleEmojiProvider
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.emojibasebindings.EmojibaseCategory
|
||||
import io.element.android.emojibasebindings.EmojibaseDatasource
|
||||
import io.element.android.emojibasebindings.EmojibaseStore
|
||||
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
|
||||
|
|
@ -59,24 +63,23 @@ import kotlinx.coroutines.launch
|
|||
@Composable
|
||||
fun EmojiPicker(
|
||||
onEmojiSelected: (Emoji) -> Unit,
|
||||
emojibaseStore: EmojibaseStore,
|
||||
selectedEmojis: ImmutableSet<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val emojiProvider = remember { GoogleEmojiProvider() }
|
||||
val categories = remember { emojiProvider.categories }
|
||||
val pagerState = rememberPagerState(pageCount = { emojiProvider.categories.size })
|
||||
val categories = remember { emojibaseStore.categories }
|
||||
val pagerState = rememberPagerState(pageCount = { EmojibaseCategory.values().size })
|
||||
Column(modifier) {
|
||||
TabRow(
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
) {
|
||||
categories.forEachIndexed { index, category ->
|
||||
EmojibaseCategory.values().forEachIndexed { index, category ->
|
||||
Tab(
|
||||
text = {
|
||||
Icon(
|
||||
resourceId = emojiProvider.getIcon(category),
|
||||
contentDescription = category.categoryNames["en"]
|
||||
imageVector = category.icon,
|
||||
contentDescription = stringResource(id = category.title)
|
||||
)
|
||||
},
|
||||
selected = pagerState.currentPage == index,
|
||||
|
|
@ -91,14 +94,16 @@ fun EmojiPicker(
|
|||
state = pagerState,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { index ->
|
||||
val category = categories[index]
|
||||
val category = EmojibaseCategory.values()[index]
|
||||
val emojis = categories[category] ?: listOf()
|
||||
LazyVerticalGrid(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
columns = GridCells.Adaptive(minSize = 40.dp),
|
||||
contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(category.emojis, key = { it.unicode }) { item ->
|
||||
|
||||
items(emojis, key = { it.unicode }) { item ->
|
||||
val backgroundColor = if (selectedEmojis.contains(item.unicode)) {
|
||||
ElementTheme.colors.bgActionPrimaryRest
|
||||
} else {
|
||||
|
|
@ -144,7 +149,8 @@ internal fun EmojiPickerDarkPreview() {
|
|||
private fun ContentToPreview() {
|
||||
EmojiPicker(
|
||||
onEmojiSelected = {},
|
||||
emojibaseStore = EmojibaseDatasource().load(LocalContext.current),
|
||||
selectedEmojis = persistentSetOf("😀", "😄", "😃"),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
selectedEmojis = persistentSetOf("😀", "😄", "😃")
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.customreaction
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.EmojiEvents
|
||||
import androidx.compose.material.icons.outlined.EmojiFlags
|
||||
import androidx.compose.material.icons.outlined.EmojiFoodBeverage
|
||||
import androidx.compose.material.icons.outlined.EmojiNature
|
||||
import androidx.compose.material.icons.outlined.EmojiObjects
|
||||
import androidx.compose.material.icons.outlined.EmojiPeople
|
||||
import androidx.compose.material.icons.outlined.EmojiSymbols
|
||||
import androidx.compose.material.icons.outlined.EmojiTransportation
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import io.element.android.emojibasebindings.EmojibaseCategory
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@get:StringRes
|
||||
val EmojibaseCategory.title: Int get() =
|
||||
when(this){
|
||||
EmojibaseCategory.People -> CommonStrings.emoji_picker_category_people
|
||||
EmojibaseCategory.Nature -> CommonStrings.emoji_picker_category_nature
|
||||
EmojibaseCategory.Foods -> CommonStrings.emoji_picker_category_foods
|
||||
EmojibaseCategory.Activity -> CommonStrings.emoji_picker_category_activity
|
||||
EmojibaseCategory.Places -> CommonStrings.emoji_picker_category_places
|
||||
EmojibaseCategory.Objects -> CommonStrings.emoji_picker_category_objects
|
||||
EmojibaseCategory.Symbols -> CommonStrings.emoji_picker_category_symbols
|
||||
EmojibaseCategory.Flags -> CommonStrings.emoji_picker_category_flags
|
||||
}
|
||||
|
||||
val EmojibaseCategory.icon: ImageVector
|
||||
get() =
|
||||
when(this){
|
||||
EmojibaseCategory.People -> Icons.Outlined.EmojiPeople
|
||||
EmojibaseCategory.Nature -> Icons.Outlined.EmojiNature
|
||||
EmojibaseCategory.Foods -> Icons.Outlined.EmojiFoodBeverage
|
||||
EmojibaseCategory.Activity -> Icons.Outlined.EmojiEvents
|
||||
EmojibaseCategory.Places -> Icons.Outlined.EmojiTransportation
|
||||
EmojibaseCategory.Objects -> Icons.Outlined.EmojiObjects
|
||||
EmojibaseCategory.Symbols -> Icons.Outlined.EmojiSymbols
|
||||
EmojibaseCategory.Flags -> Icons.Outlined.EmojiFlags
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* 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.customreaction
|
||||
|
||||
import io.element.android.emojibasebindings.EmojibaseStore
|
||||
|
||||
interface EmojibaseProvider {
|
||||
val emojibaseStore: EmojibaseStore
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
@Composable
|
||||
fun TimelineItemEventContentView(
|
||||
|
|
@ -39,6 +40,7 @@ fun TimelineItemEventContentView(
|
|||
extraPadding: ExtraPadding,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onPollAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
when (content) {
|
||||
|
|
@ -93,7 +95,7 @@ fun TimelineItemEventContentView(
|
|||
)
|
||||
is TimelineItemPollContent -> TimelineItemPollView(
|
||||
content = content,
|
||||
onAnswerSelected = {},
|
||||
onAnswerSelected = onPollAnswerSelected,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,16 +24,17 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.poll.api.PollContentView
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.matrix.api.poll.PollAnswer
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Composable
|
||||
fun TimelineItemPollView(
|
||||
content: TimelineItemPollContent,
|
||||
onAnswerSelected: (PollAnswer) -> Unit,
|
||||
onAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
PollContentView(
|
||||
eventId = content.eventId,
|
||||
question = content.question,
|
||||
answerItems = content.answerItems.toImmutableList(),
|
||||
pollKind = content.pollKind,
|
||||
|
|
@ -49,6 +50,6 @@ internal fun TimelineItemPollViewPreview(@PreviewParameter(TimelineItemPollConte
|
|||
ElementPreview {
|
||||
TimelineItemPollView(
|
||||
content = content,
|
||||
onAnswerSelected = {},
|
||||
onAnswerSelected = { _, _ -> },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ import androidx.compose.foundation.layout.Box
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
|
@ -35,6 +37,7 @@ import io.element.android.libraries.designsystem.components.ClickableLinkText
|
|||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.text.toAnnotatedString
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
@Composable
|
||||
fun TimelineItemTextView(
|
||||
|
|
@ -45,31 +48,33 @@ fun TimelineItemTextView(
|
|||
onTextClicked: () -> Unit = {},
|
||||
onTextLongClicked: () -> Unit = {},
|
||||
) {
|
||||
val htmlDocument = content.htmlDocument
|
||||
if (htmlDocument != null) {
|
||||
// For now we ignore the extra padding for html content, so add some spacing
|
||||
// below the content (as previous behavior)
|
||||
Column(modifier = modifier) {
|
||||
HtmlDocument(
|
||||
document = htmlDocument,
|
||||
modifier = Modifier,
|
||||
onTextClicked = onTextClicked,
|
||||
onTextLongClicked = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
} else {
|
||||
Box(modifier) {
|
||||
val textWithPadding = remember(content.body) {
|
||||
content.body + extraPadding.getStr(16.sp).toAnnotatedString()
|
||||
CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textPrimary) {
|
||||
val htmlDocument = content.htmlDocument
|
||||
if (htmlDocument != null) {
|
||||
// For now we ignore the extra padding for html content, so add some spacing
|
||||
// below the content (as previous behavior)
|
||||
Column(modifier = modifier) {
|
||||
HtmlDocument(
|
||||
document = htmlDocument,
|
||||
modifier = Modifier,
|
||||
onTextClicked = onTextClicked,
|
||||
onTextLongClicked = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
} else {
|
||||
Box(modifier) {
|
||||
val textWithPadding = remember(content.body) {
|
||||
content.body + extraPadding.getStr(16.sp).toAnnotatedString()
|
||||
}
|
||||
ClickableLinkText(
|
||||
text = textWithPadding,
|
||||
onClick = onTextClicked,
|
||||
onLongClick = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
}
|
||||
ClickableLinkText(
|
||||
text = textWithPadding,
|
||||
onClick = onTextClicked,
|
||||
onLongClick = onTextLongClicked,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class TimelineItemContentFactory @Inject constructor(
|
|||
is RoomMembershipContent -> roomMembershipFactory.create(eventTimelineItem)
|
||||
is StateContent -> stateFactory.create(eventTimelineItem)
|
||||
is StickerContent -> stickerFactory.create(itemContent)
|
||||
is PollContent -> pollFactory.create(itemContent)
|
||||
is PollContent -> pollFactory.create(itemContent, eventTimelineItem.eventId)
|
||||
is UnableToDecryptContent -> utdFactory.create(itemContent)
|
||||
is UnknownContent -> TimelineItemUnknownContent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,10 +132,12 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
|||
}
|
||||
|
||||
private fun aspectRatioOf(width: Long?, height: Long?): Float? {
|
||||
return if (height != null && width != null) {
|
||||
val result = if (height != null && width != null) {
|
||||
width.toFloat() / height.toFloat()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return result?.takeIf { it.isFinite() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import io.element.android.features.poll.api.PollAnswerItem
|
|||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.poll.isDisclosed
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
||||
import javax.inject.Inject
|
||||
|
|
@ -32,7 +33,10 @@ class TimelineItemContentPollFactory @Inject constructor(
|
|||
private val featureFlagService: FeatureFlagService,
|
||||
) {
|
||||
|
||||
suspend fun create(content: PollContent): TimelineItemEventContent {
|
||||
suspend fun create(
|
||||
content: PollContent,
|
||||
eventId: EventId?
|
||||
): TimelineItemEventContent {
|
||||
if (!featureFlagService.isFeatureEnabled(FeatureFlags.Polls)) return TimelineItemUnknownContent
|
||||
|
||||
// Todo Move this computation to the matrix rust sdk
|
||||
|
|
@ -67,9 +71,9 @@ class TimelineItemContentPollFactory @Inject constructor(
|
|||
}
|
||||
|
||||
return TimelineItemPollContent(
|
||||
eventId = eventId,
|
||||
question = content.question,
|
||||
answerItems = answerItems,
|
||||
votes = content.votes,
|
||||
pollKind = content.kind,
|
||||
isEnded = isEndedPoll,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ package io.element.android.features.messages.impl.timeline.model.event
|
|||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.features.poll.api.PollAnswerItem
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.poll.PollAnswer
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
|
||||
open class TimelineItemLocationContentProvider : PreviewParameterProvider<TimelineItemLocationContent> {
|
||||
override val values: Sequence<TimelineItemLocationContent>
|
||||
|
|
@ -36,3 +40,32 @@ fun aTimelineItemLocationContent(description: String? = null) = TimelineItemLoca
|
|||
),
|
||||
description = description,
|
||||
)
|
||||
|
||||
fun aTimelineItemPollContent(
|
||||
isEnded: Boolean = false,
|
||||
) = TimelineItemPollContent(
|
||||
eventId = EventId("\$anEventId"),
|
||||
question = "Some question?",
|
||||
answerItems = listOf(
|
||||
PollAnswerItem(
|
||||
answer = PollAnswer("id_1", "Answer1"),
|
||||
isSelected = false,
|
||||
isEnabled = false,
|
||||
isWinner = false,
|
||||
isDisclosed = false,
|
||||
votesCount = 0,
|
||||
percentage = 0.0f,
|
||||
),
|
||||
PollAnswerItem(
|
||||
answer = PollAnswer("id_2", "Answer2"),
|
||||
isSelected = false,
|
||||
isEnabled = false,
|
||||
isWinner = false,
|
||||
isDisclosed = false,
|
||||
votesCount = 0,
|
||||
percentage = 0.0f,
|
||||
),
|
||||
),
|
||||
pollKind = PollKind.Disclosed,
|
||||
isEnded = isEnded,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,13 +17,13 @@
|
|||
package io.element.android.features.messages.impl.timeline.model.event
|
||||
|
||||
import io.element.android.features.poll.api.PollAnswerItem
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
|
||||
data class TimelineItemPollContent(
|
||||
val eventId: EventId?,
|
||||
val question: String,
|
||||
val answerItems: List<PollAnswerItem>,
|
||||
val votes: Map<String, List<UserId>>,
|
||||
val pollKind: PollKind,
|
||||
val isEnded: Boolean,
|
||||
) : TimelineItemEventContent {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.model.event
|
|||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.poll.api.aPollAnswerItemList
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
|
||||
open class TimelineItemPollContentProvider : PreviewParameterProvider<TimelineItemPollContent> {
|
||||
|
|
@ -30,10 +31,10 @@ open class TimelineItemPollContentProvider : PreviewParameterProvider<TimelineIt
|
|||
|
||||
fun aTimelineItemPollContent(): TimelineItemPollContent {
|
||||
return TimelineItemPollContent(
|
||||
eventId = EventId("\$anEventId"),
|
||||
pollKind = PollKind.Disclosed,
|
||||
question = "What type of food should we have at the party?",
|
||||
answerItems = aPollAnswerItemList(),
|
||||
isEnded = false,
|
||||
votes = emptyMap(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ class MessageSummaryFormatterImpl @Inject constructor(
|
|||
is TimelineItemLocationContent -> context.getString(CommonStrings.common_shared_location)
|
||||
is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt)
|
||||
is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed)
|
||||
is TimelineItemPollContent, // Todo Polls: handle summary
|
||||
is TimelineItemPollContent -> event.content.question
|
||||
is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event)
|
||||
is TimelineItemImageContent -> context.getString(CommonStrings.common_image)
|
||||
is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||
import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider
|
||||
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
|
||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
|
||||
|
|
@ -72,6 +73,7 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
|
|||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.consumeItemsUntilTimeout
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import io.element.android.tests.testutils.waitForPredicate
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -559,6 +561,24 @@ class MessagesPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle poll end`() = runTest {
|
||||
val room = FakeMatrixRoom()
|
||||
val presenter = createMessagePresenter(matrixRoom = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EndPoll, aMessageEvent()))
|
||||
waitForPredicate { room.endPollInvocations.size == 1 }
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
assertThat(room.endPollInvocations.size).isEqualTo(1)
|
||||
assertThat(room.endPollInvocations.first().pollStartId).isEqualTo(AN_EVENT_ID)
|
||||
assertThat(room.endPollInvocations.first().text).isEqualTo("The poll with event id: \$anEventId has ended.")
|
||||
// TODO Polls: Test poll end analytic
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createMessagePresenter(
|
||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||
matrixRoom: MatrixRoom = FakeMatrixRoom(),
|
||||
|
|
@ -584,7 +604,7 @@ class MessagesPresenterTest {
|
|||
)
|
||||
val buildMeta = aBuildMeta()
|
||||
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
|
||||
val customReactionPresenter = CustomReactionPresenter()
|
||||
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
|
||||
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
|
||||
val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom)
|
||||
return MessagesPresenter(
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
|
|
@ -369,6 +370,57 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.displayEmojiReactions).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - compute for poll message`() = runTest {
|
||||
val presenter = anActionListPresenter(isBuildDebuggable = false)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent(
|
||||
isMine = true,
|
||||
content = aTimelineItemPollContent(),
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
messageEvent,
|
||||
persistentListOf(
|
||||
TimelineItemAction.EndPoll,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(successState.displayEmojiReactions).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - compute for ended poll message`() = runTest {
|
||||
val presenter = anActionListPresenter(isBuildDebuggable = false)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent(
|
||||
isMine = true,
|
||||
content = aTimelineItemPollContent(isEnded = true),
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
messageEvent,
|
||||
persistentListOf(
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(successState.displayEmojiReactions).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun anActionListPresenter(isBuildDebuggable: Boolean) = ActionListPresenter(buildMeta = aBuildMeta(isDebuggable = isBuildDebuggable))
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import io.element.android.features.messages.impl.timeline.TimelineEvents
|
|||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
||||
|
|
@ -36,8 +36,10 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
|||
import io.element.android.libraries.matrix.test.room.aMessageContent
|
||||
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.tests.testutils.awaitWithLatch
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
|
@ -248,6 +250,23 @@ class TimelinePresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - PollAnswerSelected event calls into rust room api and analytics`() = runTest {
|
||||
val room = FakeMatrixRoom()
|
||||
val presenter = createTimelinePresenter(room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(TimelineEvents.PollAnswerSelected(AN_EVENT_ID, "anAnswerId"))
|
||||
}
|
||||
delay(1)
|
||||
assertThat(room.sendPollResponseInvocations.size).isEqualTo(1)
|
||||
assertThat(room.sendPollResponseInvocations.first().answers).isEqualTo(listOf("anAnswerId"))
|
||||
assertThat(room.sendPollResponseInvocations.first().pollStartId).isEqualTo(AN_EVENT_ID)
|
||||
// TODO Polls: Test poll vote analytic
|
||||
}
|
||||
|
||||
private fun TestScope.createTimelinePresenter(
|
||||
timeline: MatrixTimeline = FakeMatrixTimeline(),
|
||||
timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory()
|
||||
|
|
@ -259,4 +278,15 @@ class TimelinePresenterTest {
|
|||
appScope = this
|
||||
)
|
||||
}
|
||||
|
||||
private fun TestScope.createTimelinePresenter(
|
||||
room: MatrixRoom,
|
||||
): TimelinePresenter {
|
||||
return TimelinePresenter(
|
||||
timelineItemsFactory = aTimelineItemsFactory(),
|
||||
room = room,
|
||||
dispatchers = testCoroutineDispatchers(),
|
||||
appScope = this
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,27 +24,34 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
|||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class CustomReactionPresenterTests {
|
||||
|
||||
private val presenter = CustomReactionPresenter()
|
||||
private val presenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
|
||||
|
||||
@Test
|
||||
fun `present - handle selecting and de-selecting an event`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
||||
val event = aTimelineItemEvent(eventId = AN_EVENT_ID)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedEventId).isNull()
|
||||
assertThat(initialState.target).isEqualTo(CustomReactionState.Target.None)
|
||||
|
||||
initialState.eventSink(CustomReactionEvents.UpdateSelectedEvent(aTimelineItemEvent(eventId = AN_EVENT_ID)))
|
||||
assertThat(awaitItem().selectedEventId).isEqualTo(AN_EVENT_ID)
|
||||
initialState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
|
||||
|
||||
initialState.eventSink(CustomReactionEvents.UpdateSelectedEvent(null))
|
||||
assertThat(awaitItem().selectedEventId).isNull()
|
||||
assertThat(awaitItem().target).isEqualTo(CustomReactionState.Target.Loading(event))
|
||||
|
||||
val eventId = (awaitItem().target as? CustomReactionState.Target.Success)?.event?.eventId
|
||||
assertThat(eventId).isEqualTo(AN_EVENT_ID)
|
||||
|
||||
initialState.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
|
||||
assertThat(awaitItem().target).isEqualTo(CustomReactionState.Target.None)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -53,13 +60,19 @@ class CustomReactionPresenterTests {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedEventId).isNull()
|
||||
val reactions = aTimelineItemReactions(count = 1, isHighlighted = true)
|
||||
val event = aTimelineItemEvent(eventId = AN_EVENT_ID, timelineItemReactions = reactions)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.target).isEqualTo(CustomReactionState.Target.None)
|
||||
|
||||
val key = reactions.reactions.first().key
|
||||
initialState.eventSink(CustomReactionEvents.UpdateSelectedEvent(aTimelineItemEvent(eventId = AN_EVENT_ID, timelineItemReactions = reactions)))
|
||||
initialState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
|
||||
|
||||
assertThat(awaitItem().target).isEqualTo(CustomReactionState.Target.Loading(event))
|
||||
|
||||
val stateWithSelectedEmojis = awaitItem()
|
||||
assertThat(stateWithSelectedEmojis.selectedEventId).isEqualTo(AN_EVENT_ID)
|
||||
val eventId = (stateWithSelectedEmojis.target as? CustomReactionState.Target.Success)?.event?.eventId
|
||||
assertThat(eventId).isEqualTo(AN_EVENT_ID)
|
||||
assertThat(stateWithSelectedEmojis.selectedEmoji).contains(key)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.timeline.components.customreaction
|
||||
|
||||
import io.element.android.emojibasebindings.EmojibaseStore
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.EmojibaseProvider
|
||||
|
||||
class FakeEmojibaseProvider: EmojibaseProvider {
|
||||
override val emojibaseStore: EmojibaseStore
|
||||
get() = EmojibaseStore(mapOf())
|
||||
}
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.timeline.factories.event
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentPollFactory
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
|
||||
import io.element.android.features.poll.api.PollAnswerItem
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.poll.PollAnswer
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_10
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_3
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_4
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_5
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_6
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_7
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_8
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_9
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
internal class TimelineItemContentPollFactoryTest {
|
||||
|
||||
private val factory = TimelineItemContentPollFactory(
|
||||
matrixClient = FakeMatrixClient(),
|
||||
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.Polls.key to true)),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `Disclosed poll - not ended, no votes`() = runTest {
|
||||
Truth.assertThat(factory.create(aPollContent(), eventId = null)).isEqualTo(aTimelineItemPollContent())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Disclosed poll - not ended, some votes, including one from current user`() = runTest {
|
||||
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }
|
||||
Truth.assertThat(
|
||||
factory.create(aPollContent(votes = votes), eventId = null)
|
||||
)
|
||||
.isEqualTo(
|
||||
aTimelineItemPollContent(
|
||||
answerItems = listOf(
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_1, votesCount = 3, percentage = 0.3f),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, votesCount = 6, percentage = 0.6f),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_3),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_4, votesCount = 1, percentage = 0.1f),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Disclosed poll - ended, no votes, no winner`() = runTest {
|
||||
Truth.assertThat(
|
||||
factory.create(aPollContent(endTime = 1UL), eventId = null)
|
||||
).isEqualTo(
|
||||
aTimelineItemPollContent().let {
|
||||
it.copy(
|
||||
answerItems = it.answerItems.map { answerItem -> answerItem.copy(isEnabled = false) },
|
||||
isEnded = true,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Disclosed poll - ended, some votes, including one from current user (winner)`() = runTest {
|
||||
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }
|
||||
Truth.assertThat(
|
||||
factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null)
|
||||
)
|
||||
.isEqualTo(
|
||||
aTimelineItemPollContent(
|
||||
answerItems = listOf(
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, votesCount = 3, percentage = 0.3f),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, isWinner = true, votesCount = 6, percentage = 0.6f),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, votesCount = 1, percentage = 0.1f),
|
||||
),
|
||||
isEnded = true,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Disclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest {
|
||||
val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }
|
||||
Truth.assertThat(
|
||||
factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null)
|
||||
)
|
||||
.isEqualTo(
|
||||
aTimelineItemPollContent(
|
||||
answerItems = listOf(
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, votesCount = 2, percentage = 0.2f),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f),
|
||||
),
|
||||
isEnded = true,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Undisclosed poll - not ended, no votes`() = runTest {
|
||||
Truth.assertThat(
|
||||
factory.create(aPollContent(PollKind.Undisclosed).copy(), eventId = null)
|
||||
).isEqualTo(
|
||||
aTimelineItemPollContent(pollKind = PollKind.Undisclosed).let {
|
||||
it.copy(answerItems = it.answerItems.map { answerItem -> answerItem.copy(isDisclosed = false) })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Undisclosed poll - not ended, some votes, including one from current user`() = runTest {
|
||||
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }
|
||||
Truth.assertThat(
|
||||
factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes), eventId = null)
|
||||
)
|
||||
.isEqualTo(
|
||||
aTimelineItemPollContent(
|
||||
pollKind = PollKind.Undisclosed,
|
||||
answerItems = listOf(
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_1, isDisclosed = false, votesCount = 3, percentage = 0.3f),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_2, isDisclosed = false, isSelected = true, votesCount = 6, percentage = 0.6f),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_3, isDisclosed = false),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_4, isDisclosed = false, votesCount = 1, percentage = 0.1f),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Undisclosed poll - ended, no votes, no winner`() = runTest {
|
||||
Truth.assertThat(
|
||||
factory.create(aPollContent(pollKind = PollKind.Undisclosed, endTime = 1UL), eventId = null)
|
||||
).isEqualTo(
|
||||
aTimelineItemPollContent().let {
|
||||
it.copy(
|
||||
pollKind = PollKind.Undisclosed,
|
||||
answerItems = it.answerItems.map { answerItem ->
|
||||
answerItem.copy(isDisclosed = true, isEnabled = false, isWinner = false)
|
||||
},
|
||||
isEnded = true,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Undisclosed poll - ended, some votes, including one from current user (winner)`() = runTest {
|
||||
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }
|
||||
Truth.assertThat(
|
||||
factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes, endTime = 1UL), eventId = null)
|
||||
)
|
||||
.isEqualTo(
|
||||
aTimelineItemPollContent(
|
||||
pollKind = PollKind.Undisclosed,
|
||||
answerItems = listOf(
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, votesCount = 3, percentage = 0.3f),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, isWinner = true, votesCount = 6, percentage = 0.6f),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, votesCount = 1, percentage = 0.1f),
|
||||
),
|
||||
isEnded = true,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Undisclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest {
|
||||
val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }
|
||||
Truth.assertThat(
|
||||
factory.create(aPollContent(PollKind.Undisclosed).copy(votes = votes, endTime = 1UL), eventId = null)
|
||||
)
|
||||
.isEqualTo(
|
||||
aTimelineItemPollContent(
|
||||
pollKind = PollKind.Undisclosed,
|
||||
answerItems = listOf(
|
||||
aPollAnswerItem(A_POLL_ANSWER_1, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f),
|
||||
aPollAnswerItem(A_POLL_ANSWER_2, isSelected = true, isEnabled = false, votesCount = 2, percentage = 0.2f),
|
||||
aPollAnswerItem(A_POLL_ANSWER_3, isEnabled = false),
|
||||
aPollAnswerItem(A_POLL_ANSWER_4, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f),
|
||||
),
|
||||
isEnded = true,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eventId is populated`() = runTest {
|
||||
Truth.assertThat(factory.create(aPollContent(), eventId = null))
|
||||
.isEqualTo(aTimelineItemPollContent(eventId = null))
|
||||
|
||||
Truth.assertThat(factory.create(aPollContent(), eventId = AN_EVENT_ID))
|
||||
.isEqualTo(aTimelineItemPollContent(eventId = AN_EVENT_ID))
|
||||
}
|
||||
|
||||
private fun aPollContent(
|
||||
pollKind: PollKind = PollKind.Disclosed,
|
||||
votes: Map<String, List<UserId>> = emptyMap(),
|
||||
endTime: ULong? = null,
|
||||
): PollContent = PollContent(
|
||||
question = A_POLL_QUESTION,
|
||||
kind = pollKind,
|
||||
maxSelections = 1UL,
|
||||
answers = listOf(A_POLL_ANSWER_1, A_POLL_ANSWER_2, A_POLL_ANSWER_3, A_POLL_ANSWER_4),
|
||||
votes = votes,
|
||||
endTime = endTime,
|
||||
)
|
||||
|
||||
private fun aTimelineItemPollContent(
|
||||
eventId: EventId? = null,
|
||||
pollKind: PollKind = PollKind.Disclosed,
|
||||
answerItems: List<PollAnswerItem> = listOf(
|
||||
aPollAnswerItem(A_POLL_ANSWER_1),
|
||||
aPollAnswerItem(A_POLL_ANSWER_2),
|
||||
aPollAnswerItem(A_POLL_ANSWER_3),
|
||||
aPollAnswerItem(A_POLL_ANSWER_4),
|
||||
),
|
||||
isEnded: Boolean = false,
|
||||
) = TimelineItemPollContent(
|
||||
eventId = eventId,
|
||||
question = A_POLL_QUESTION,
|
||||
answerItems = answerItems,
|
||||
pollKind = pollKind,
|
||||
isEnded = isEnded,
|
||||
)
|
||||
|
||||
private fun aPollAnswerItem(
|
||||
answer: PollAnswer,
|
||||
isSelected: Boolean = false,
|
||||
isEnabled: Boolean = true,
|
||||
isWinner: Boolean = false,
|
||||
isDisclosed: Boolean = true,
|
||||
votesCount: Int = 0,
|
||||
percentage: Float = 0f,
|
||||
) = PollAnswerItem(
|
||||
answer = answer,
|
||||
isSelected = isSelected,
|
||||
isEnabled = isEnabled,
|
||||
isWinner = isWinner,
|
||||
isDisclosed = isDisclosed,
|
||||
votesCount = votesCount,
|
||||
percentage = percentage,
|
||||
)
|
||||
|
||||
private companion object TestData {
|
||||
private const val A_POLL_QUESTION = "What is your favorite food?"
|
||||
private val A_POLL_ANSWER_1 = PollAnswer("id_1", "Pizza")
|
||||
private val A_POLL_ANSWER_2 = PollAnswer("id_2", "Pasta")
|
||||
private val A_POLL_ANSWER_3 = PollAnswer("id_3", "French Fries")
|
||||
private val A_POLL_ANSWER_4 = PollAnswer("id_4", "Hamburger")
|
||||
|
||||
private val MY_USER_WINNING_VOTES = mapOf(
|
||||
A_POLL_ANSWER_1 to listOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4),
|
||||
A_POLL_ANSWER_2 to listOf(A_USER_ID /* my vote */, A_USER_ID_5, A_USER_ID_6, A_USER_ID_7, A_USER_ID_8, A_USER_ID_9), // winner
|
||||
A_POLL_ANSWER_3 to emptyList(),
|
||||
A_POLL_ANSWER_4 to listOf(A_USER_ID_10),
|
||||
)
|
||||
private val OTHER_WINNING_VOTES = mapOf(
|
||||
A_POLL_ANSWER_1 to listOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4, A_USER_ID_5), // winner
|
||||
A_POLL_ANSWER_2 to listOf(A_USER_ID /* my vote */, A_USER_ID_6),
|
||||
A_POLL_ANSWER_3 to emptyList(),
|
||||
A_POLL_ANSWER_4 to listOf(A_USER_ID_7, A_USER_ID_8, A_USER_ID_9, A_USER_ID_10), // winner
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -31,10 +31,12 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.VectorIcons
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.poll.PollAnswer
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
|
@ -43,22 +45,27 @@ import kotlinx.collections.immutable.ImmutableList
|
|||
|
||||
@Composable
|
||||
fun PollContentView(
|
||||
eventId: EventId?,
|
||||
question: String,
|
||||
answerItems: ImmutableList<PollAnswerItem>,
|
||||
pollKind: PollKind,
|
||||
isPollEnded: Boolean,
|
||||
onAnswerSelected: (PollAnswer) -> Unit,
|
||||
onAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun onAnswerSelected(pollAnswer: PollAnswer) {
|
||||
eventId?.let { onAnswerSelected(it, pollAnswer.id) }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.selectableGroup()
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
PollTitle(title = question)
|
||||
PollTitle(title = question, isPollEnded = isPollEnded)
|
||||
|
||||
PollAnswers(answerItems = answerItems, onAnswerSelected = onAnswerSelected)
|
||||
PollAnswers(answerItems = answerItems, onAnswerSelected = ::onAnswerSelected)
|
||||
|
||||
when {
|
||||
isPollEnded || pollKind == PollKind.Disclosed -> DisclosedPollBottomNotice(answerItems)
|
||||
|
|
@ -70,17 +77,26 @@ fun PollContentView(
|
|||
@Composable
|
||||
internal fun PollTitle(
|
||||
title: String,
|
||||
isPollEnded: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(22.dp),
|
||||
imageVector = Icons.Outlined.Poll,
|
||||
contentDescription = null
|
||||
)
|
||||
if (isPollEnded) {
|
||||
Icon(
|
||||
resourceId = VectorIcons.EndPoll,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Poll,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = ElementTheme.typography.fontBodyLgMedium
|
||||
|
|
@ -134,11 +150,12 @@ fun ColumnScope.UndisclosedPollBottomNotice(modifier: Modifier = Modifier) {
|
|||
@Composable
|
||||
internal fun PollContentUndisclosedPreview() = ElementPreview {
|
||||
PollContentView(
|
||||
eventId = EventId("\$anEventId"),
|
||||
question = "What type of food should we have at the party?",
|
||||
answerItems = aPollAnswerItemList(isDisclosed = false),
|
||||
pollKind = PollKind.Undisclosed,
|
||||
isPollEnded = false,
|
||||
onAnswerSelected = { },
|
||||
onAnswerSelected = { _, _ -> },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -146,11 +163,12 @@ internal fun PollContentUndisclosedPreview() = ElementPreview {
|
|||
@Composable
|
||||
internal fun PollContentDisclosedPreview() = ElementPreview {
|
||||
PollContentView(
|
||||
eventId = EventId("\$anEventId"),
|
||||
question = "What type of food should we have at the party?",
|
||||
answerItems = aPollAnswerItemList(),
|
||||
pollKind = PollKind.Disclosed,
|
||||
isPollEnded = false,
|
||||
onAnswerSelected = { },
|
||||
onAnswerSelected = { _, _ -> },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -158,10 +176,11 @@ internal fun PollContentDisclosedPreview() = ElementPreview {
|
|||
@Composable
|
||||
internal fun PollContentEndedPreview() = ElementPreview {
|
||||
PollContentView(
|
||||
eventId = EventId("\$anEventId"),
|
||||
question = "What type of food should we have at the party?",
|
||||
answerItems = aPollAnswerItemList(isEnded = true),
|
||||
pollKind = PollKind.Disclosed,
|
||||
isPollEnded = false,
|
||||
onAnswerSelected = { },
|
||||
isPollEnded = true,
|
||||
onAnswerSelected = { _, _ -> },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,24 +14,12 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.poll.api
|
||||
package io.element.android.features.poll.api.create
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
|
||||
interface PollEntryPoint : FeatureEntryPoint {
|
||||
|
||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||
|
||||
interface NodeBuilder {
|
||||
fun callback(callback: Callback): NodeBuilder
|
||||
fun build(): Node
|
||||
}
|
||||
|
||||
interface Callback : Plugin {
|
||||
// Add your callbacks
|
||||
}
|
||||
interface CreatePollEntryPoint : FeatureEntryPoint {
|
||||
fun createNode(parentNode: Node, buildContext: BuildContext): Node
|
||||
}
|
||||
|
||||
|
|
@ -14,8 +14,6 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
|
||||
@Suppress("DSL_SCOPE_VIOLATION")
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
alias(libs.plugins.anvil)
|
||||
|
|
@ -40,6 +38,8 @@ dependencies {
|
|||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
|
|
@ -47,6 +47,7 @@ dependencies {
|
|||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,70 +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.poll.impl
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.composable.Children
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class PollFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
) : BackstackNode<PollFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Root,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
) {
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object Root : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Root -> {
|
||||
createNode(buildContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Children(
|
||||
navModel = backstack,
|
||||
modifier = modifier,
|
||||
transitionHandler = rememberDefaultTransitionHandler(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.poll.impl.create
|
||||
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
|
||||
sealed interface CreatePollEvents {
|
||||
data object Create : CreatePollEvents
|
||||
data class SetQuestion(val question: String) : CreatePollEvents
|
||||
data class SetAnswer(val index: Int, val text: String) : CreatePollEvents
|
||||
data object AddAnswer : CreatePollEvents
|
||||
data class RemoveAnswer(val index: Int) : CreatePollEvents
|
||||
data class SetPollKind(val pollKind: PollKind) : CreatePollEvents
|
||||
data object NavBack : CreatePollEvents
|
||||
data object ConfirmNavBack : CreatePollEvents
|
||||
data object HideConfirmation : CreatePollEvents
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.poll.impl.create
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
class CreatePollNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: CreatePollPresenter.Factory,
|
||||
// analyticsService: AnalyticsService, // TODO Polls: add analytics
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
private val presenter = presenterFactory.create(backNavigator = ::navigateUp)
|
||||
|
||||
init {
|
||||
lifecycle.subscribe(
|
||||
onResume = {
|
||||
// TODO Polls: add analytics
|
||||
// analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.PollView))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
CreatePollView(
|
||||
state = presenter.present(),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.poll.impl.create
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
private const val MIN_ANSWERS = 2
|
||||
private const val MAX_ANSWERS = 20
|
||||
private const val MAX_ANSWER_LENGTH = 240
|
||||
private const val MAX_SELECTIONS = 1
|
||||
|
||||
class CreatePollPresenter @AssistedInject constructor(
|
||||
private val room: MatrixRoom,
|
||||
// private val analyticsService: AnalyticsService, // TODO Polls: add analytics
|
||||
@Assisted private val navigateUp: () -> Unit,
|
||||
// private val messageComposerContext: MessageComposerContext, // TODO Polls: add analytics
|
||||
) : Presenter<CreatePollState> {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(backNavigator: () -> Unit): CreatePollPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): CreatePollState {
|
||||
|
||||
var question: String by rememberSaveable { mutableStateOf("") }
|
||||
var answers: List<String> by rememberSaveable() { mutableStateOf(listOf("", "")) }
|
||||
var pollKind: PollKind by rememberSaveable(saver = pollKindSaver) { mutableStateOf(PollKind.Disclosed) }
|
||||
var showConfirmation: Boolean by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val canCreate: Boolean by remember { derivedStateOf { canCreate(question, answers) } }
|
||||
val canAddAnswer: Boolean by remember { derivedStateOf { canAddAnswer(answers) } }
|
||||
val immutableAnswers: ImmutableList<Answer> by remember { derivedStateOf { answers.toAnswers() } }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun handleEvents(event: CreatePollEvents) {
|
||||
when (event) {
|
||||
is CreatePollEvents.Create -> scope.launch {
|
||||
if (canCreate) {
|
||||
room.createPoll(
|
||||
question = question,
|
||||
answers = answers,
|
||||
maxSelections = MAX_SELECTIONS,
|
||||
pollKind = pollKind,
|
||||
)
|
||||
// analyticsService.capture(PollCreate()) // TODO Polls: add analytics
|
||||
navigateUp()
|
||||
} else {
|
||||
Timber.d("Cannot create poll")
|
||||
}
|
||||
}
|
||||
is CreatePollEvents.AddAnswer -> {
|
||||
answers = answers + ""
|
||||
}
|
||||
is CreatePollEvents.RemoveAnswer -> {
|
||||
answers = answers.filterIndexed { index, _ -> index != event.index }
|
||||
}
|
||||
is CreatePollEvents.SetAnswer -> {
|
||||
answers = answers.toMutableList().apply {
|
||||
this[event.index] = event.text.take(MAX_ANSWER_LENGTH)
|
||||
}
|
||||
}
|
||||
is CreatePollEvents.SetPollKind -> {
|
||||
pollKind = event.pollKind
|
||||
}
|
||||
is CreatePollEvents.SetQuestion -> {
|
||||
question = event.question
|
||||
}
|
||||
is CreatePollEvents.NavBack -> {
|
||||
navigateUp()
|
||||
}
|
||||
CreatePollEvents.ConfirmNavBack -> {
|
||||
val shouldConfirm = question.isNotBlank() || answers.any { it.isNotBlank() }
|
||||
if (shouldConfirm) {
|
||||
showConfirmation = true
|
||||
} else {
|
||||
navigateUp()
|
||||
}
|
||||
}
|
||||
is CreatePollEvents.HideConfirmation -> showConfirmation = false
|
||||
}
|
||||
}
|
||||
|
||||
return CreatePollState(
|
||||
canCreate = canCreate,
|
||||
canAddAnswer = canAddAnswer,
|
||||
question = question,
|
||||
answers = immutableAnswers,
|
||||
pollKind = pollKind,
|
||||
showConfirmation = showConfirmation,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun canCreate(
|
||||
question: String,
|
||||
answers: List<String>
|
||||
) = question.isNotBlank() && answers.size >= MIN_ANSWERS && answers.all { it.isNotBlank() }
|
||||
|
||||
private fun canAddAnswer(answers: List<String>) = answers.size < MAX_ANSWERS
|
||||
|
||||
private fun List<String>.toAnswers(): ImmutableList<Answer> {
|
||||
return map { answer ->
|
||||
Answer(
|
||||
text = answer,
|
||||
canDelete = this.size > MIN_ANSWERS,
|
||||
)
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
private val pollKindSaver: Saver<MutableState<PollKind>, Boolean> = Saver(
|
||||
save = {
|
||||
when (it.value) {
|
||||
PollKind.Disclosed -> false
|
||||
PollKind.Undisclosed -> true
|
||||
}
|
||||
},
|
||||
restore = {
|
||||
mutableStateOf(
|
||||
when(it) {
|
||||
true -> PollKind.Undisclosed
|
||||
else -> PollKind.Disclosed
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.poll.impl.create
|
||||
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class CreatePollState(
|
||||
val canCreate: Boolean,
|
||||
val canAddAnswer: Boolean,
|
||||
val question: String,
|
||||
val answers: ImmutableList<Answer>,
|
||||
val pollKind: PollKind,
|
||||
val showConfirmation: Boolean,
|
||||
val eventSink: (CreatePollEvents) -> Unit = {},
|
||||
)
|
||||
|
||||
data class Answer(
|
||||
val text: String,
|
||||
val canDelete: Boolean,
|
||||
)
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.poll.impl.create
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
|
||||
override val values: Sequence<CreatePollState>
|
||||
get() = sequenceOf(
|
||||
CreatePollState(
|
||||
canCreate = false,
|
||||
canAddAnswer = true,
|
||||
question = "",
|
||||
answers = persistentListOf(
|
||||
Answer("", false),
|
||||
Answer("", false)
|
||||
),
|
||||
pollKind = PollKind.Disclosed,
|
||||
showConfirmation = false,
|
||||
),
|
||||
CreatePollState(
|
||||
canCreate = true,
|
||||
canAddAnswer = true,
|
||||
question = "What type of food should we have?",
|
||||
answers = persistentListOf(
|
||||
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false),
|
||||
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false),
|
||||
),
|
||||
showConfirmation = false,
|
||||
pollKind = PollKind.Undisclosed,
|
||||
),
|
||||
CreatePollState(
|
||||
canCreate = true,
|
||||
canAddAnswer = true,
|
||||
question = "What type of food should we have?",
|
||||
answers = persistentListOf(
|
||||
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false),
|
||||
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false),
|
||||
),
|
||||
showConfirmation = true,
|
||||
pollKind = PollKind.Undisclosed,
|
||||
),
|
||||
CreatePollState(
|
||||
canCreate = true,
|
||||
canAddAnswer = true,
|
||||
question = "What type of food should we have?",
|
||||
answers = persistentListOf(
|
||||
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", true),
|
||||
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", true),
|
||||
Answer("Brazilian \uD83C\uDDE7\uD83C\uDDF7", true),
|
||||
Answer("French \uD83C\uDDEB\uD83C\uDDF7", true),
|
||||
),
|
||||
showConfirmation = false,
|
||||
pollKind = PollKind.Undisclosed,
|
||||
),
|
||||
CreatePollState(
|
||||
canCreate = true,
|
||||
canAddAnswer = false,
|
||||
question = "Should there be more than 20 answers?",
|
||||
answers = persistentListOf(
|
||||
Answer("1", true),
|
||||
Answer("2", true),
|
||||
Answer("3", true),
|
||||
Answer("4", true),
|
||||
Answer("5", true),
|
||||
Answer("6", true),
|
||||
Answer("7", true),
|
||||
Answer("8", true),
|
||||
Answer("9", true),
|
||||
Answer("10", true),
|
||||
Answer("11", true),
|
||||
Answer("12", true),
|
||||
Answer("13", true),
|
||||
Answer("14", true),
|
||||
Answer("15", true),
|
||||
Answer("16", true),
|
||||
Answer("17", true),
|
||||
Answer("18", true),
|
||||
Answer("19", true),
|
||||
Answer("20", true),
|
||||
),
|
||||
showConfirmation = false,
|
||||
pollKind = PollKind.Undisclosed,
|
||||
),
|
||||
CreatePollState(
|
||||
canCreate = true,
|
||||
canAddAnswer = true,
|
||||
question = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." +
|
||||
" Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor" +
|
||||
" in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt" +
|
||||
" in culpa qui officia deserunt mollit anim id est laborum.",
|
||||
answers = persistentListOf(
|
||||
Answer(
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." +
|
||||
" Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis a.",
|
||||
false
|
||||
),
|
||||
Answer(
|
||||
"Laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore" +
|
||||
" eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mol.",
|
||||
false
|
||||
),
|
||||
),
|
||||
showConfirmation = false,
|
||||
pollKind = PollKind.Undisclosed,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.poll.impl.create
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.poll.impl.R
|
||||
import io.element.android.libraries.designsystem.VectorIcons
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CreatePollView(
|
||||
state: CreatePollState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val navBack = { state.eventSink(CreatePollEvents.ConfirmNavBack) }
|
||||
BackHandler(onBack = navBack)
|
||||
if (state.showConfirmation) ConfirmationDialog(
|
||||
content = stringResource(id = R.string.screen_create_poll_discard_confirmation),
|
||||
onSubmitClicked = { state.eventSink(CreatePollEvents.NavBack) },
|
||||
onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) }
|
||||
)
|
||||
val questionFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
questionFocusRequester.requestFocus()
|
||||
}
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.screen_create_poll_title),
|
||||
style = ElementTheme.typography.aliasScreenTitle,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
BackButton(onClick = navBack)
|
||||
},
|
||||
actions = {
|
||||
TextButton(
|
||||
text = stringResource(id = CommonStrings.action_create),
|
||||
onClick = { state.eventSink(CreatePollEvents.Create) },
|
||||
enabled = state.canCreate,
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.consumeWindowInsets(paddingValues)
|
||||
.imePadding()
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.screen_create_poll_question_desc),
|
||||
modifier = Modifier.padding(start = 32.dp),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
OutlinedTextField(
|
||||
value = state.question,
|
||||
onValueChange = {
|
||||
state.eventSink(CreatePollEvents.SetQuestion(it))
|
||||
},
|
||||
modifier = Modifier
|
||||
.focusRequester(questionFocusRequester)
|
||||
.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(text = stringResource(id = R.string.screen_create_poll_question_hint))
|
||||
},
|
||||
keyboardOptions = keyboardOptions,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
itemsIndexed(state.answers) { index, answer ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
OutlinedTextField(
|
||||
value = answer.text,
|
||||
onValueChange = {
|
||||
state.eventSink(CreatePollEvents.SetAnswer(index, it))
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(text = stringResource(id = R.string.screen_create_poll_answer_hint, index + 1))
|
||||
},
|
||||
keyboardOptions = keyboardOptions,
|
||||
)
|
||||
},
|
||||
trailingContent = ListItemContent.Custom {
|
||||
Icon(
|
||||
resourceId = VectorIcons.Delete,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.clickable(answer.canDelete) {
|
||||
state.eventSink(CreatePollEvents.RemoveAnswer(index))
|
||||
},
|
||||
)
|
||||
},
|
||||
style = if (answer.canDelete) ListItemStyle.Destructive else ListItemStyle.Default,
|
||||
)
|
||||
}
|
||||
if (state.canAddAnswer) {
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(text = stringResource(id = R.string.screen_create_poll_add_option_btn)) },
|
||||
leadingContent = ListItemContent.Icon(
|
||||
iconSource = IconSource.Vector(Icons.Default.Add),
|
||||
),
|
||||
style = ListItemStyle.Primary,
|
||||
onClick = { state.eventSink(CreatePollEvents.AddAnswer) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
HorizontalDivider()
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_headline)) },
|
||||
supportingContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_desc)) },
|
||||
trailingContent = ListItemContent.Switch(
|
||||
checked = state.pollKind == PollKind.Undisclosed,
|
||||
onChange = { state.eventSink(CreatePollEvents.SetPollKind(if (it) PollKind.Undisclosed else PollKind.Disclosed)) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
internal fun CreatePollViewPreview(
|
||||
@PreviewParameter(CreatePollStateProvider::class) state: CreatePollState
|
||||
) = ElementPreview {
|
||||
CreatePollView(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
|
||||
private val keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
imeAction = ImeAction.Next,
|
||||
)
|
||||
|
|
@ -14,33 +14,19 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.poll.impl
|
||||
package io.element.android.features.poll.impl.create
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.poll.api.PollEntryPoint
|
||||
import io.element.android.features.poll.api.create.CreatePollEntryPoint
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultPollEntryPoint @Inject constructor() : PollEntryPoint {
|
||||
|
||||
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): PollEntryPoint.NodeBuilder {
|
||||
val plugins = ArrayList<Plugin>()
|
||||
|
||||
return object : PollEntryPoint.NodeBuilder {
|
||||
|
||||
override fun callback(callback: PollEntryPoint.Callback): PollEntryPoint.NodeBuilder {
|
||||
plugins += callback
|
||||
return this
|
||||
}
|
||||
|
||||
override fun build(): Node {
|
||||
return parentNode.createNode<PollFlowNode>(buildContext, plugins)
|
||||
}
|
||||
}
|
||||
class DefaultCreatePollEntryPoint @Inject constructor() : CreatePollEntryPoint {
|
||||
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
|
||||
return parentNode.createNode<CreatePollNode>(buildContext)
|
||||
}
|
||||
}
|
||||
11
features/poll/impl/src/main/res/values/localazy.xml
Normal file
11
features/poll/impl/src/main/res/values/localazy.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_create_poll_add_option_btn">"Add option"</string>
|
||||
<string name="screen_create_poll_anonymous_desc">"Show results only after poll ends"</string>
|
||||
<string name="screen_create_poll_anonymous_headline">"Anonymous Poll"</string>
|
||||
<string name="screen_create_poll_answer_hint">"Option %1$d"</string>
|
||||
<string name="screen_create_poll_discard_confirmation">"Are you sure you would like to go back?"</string>
|
||||
<string name="screen_create_poll_question_desc">"Question or topic"</string>
|
||||
<string name="screen_create_poll_question_hint">"What is the poll about?"</string>
|
||||
<string name="screen_create_poll_title">"Create Poll"</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.poll.impl.create
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import io.element.android.libraries.matrix.test.room.CreatePollInvocation
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class CreatePollPresenterTest {
|
||||
|
||||
private var navUpInvocationsCount = 0
|
||||
private val fakeMatrixRoom = FakeMatrixRoom()
|
||||
// private val fakeAnalyticsService = FakeAnalyticsService() // TODO Polls: add analytics
|
||||
|
||||
private val presenter = CreatePollPresenter(
|
||||
room = fakeMatrixRoom,
|
||||
// analyticsService = fakeAnalyticsService, // TODO Polls: add analytics
|
||||
navigateUp = { navUpInvocationsCount++ },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `default state has proper default values`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().let {
|
||||
Truth.assertThat(it.canCreate).isEqualTo(false)
|
||||
Truth.assertThat(it.canAddAnswer).isEqualTo(true)
|
||||
Truth.assertThat(it.question).isEqualTo("")
|
||||
Truth.assertThat(it.answers).isEqualTo(listOf(Answer("", false), Answer("", false)))
|
||||
Truth.assertThat(it.pollKind).isEqualTo(PollKind.Disclosed)
|
||||
Truth.assertThat(it.showConfirmation).isEqualTo(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non blank question and 2 answers are required to create a poll`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
Truth.assertThat(initial.canCreate).isEqualTo(false)
|
||||
|
||||
initial.eventSink(CreatePollEvents.SetQuestion("A question?"))
|
||||
val questionSet = awaitItem()
|
||||
Truth.assertThat(questionSet.canCreate).isEqualTo(false)
|
||||
|
||||
questionSet.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1"))
|
||||
val answer1Set = awaitItem()
|
||||
Truth.assertThat(answer1Set.canCreate).isEqualTo(false)
|
||||
|
||||
answer1Set.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
|
||||
val answer2Set = awaitItem()
|
||||
Truth.assertThat(answer2Set.canCreate).isEqualTo(true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create polls sends a poll start event`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
initial.eventSink(CreatePollEvents.SetQuestion("A question?"))
|
||||
initial.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1"))
|
||||
initial.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
|
||||
skipItems(3)
|
||||
initial.eventSink(CreatePollEvents.Create)
|
||||
delay(1) // Wait for the coroutine to finish
|
||||
Truth.assertThat(fakeMatrixRoom.createPollInvocations.size).isEqualTo(1)
|
||||
Truth.assertThat(fakeMatrixRoom.createPollInvocations.last()).isEqualTo(
|
||||
CreatePollInvocation(
|
||||
question = "A question?",
|
||||
answers = listOf("Answer 1", "Answer 2"),
|
||||
maxSelections = 1,
|
||||
pollKind = PollKind.Disclosed
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `add answer button adds an empty answer and removing it removes it`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
Truth.assertThat(initial.answers.size).isEqualTo(2)
|
||||
|
||||
initial.eventSink(CreatePollEvents.AddAnswer)
|
||||
val answerAdded = awaitItem()
|
||||
Truth.assertThat(answerAdded.answers.size).isEqualTo(3)
|
||||
Truth.assertThat(answerAdded.answers[2].text).isEqualTo("")
|
||||
|
||||
initial.eventSink(CreatePollEvents.RemoveAnswer(2))
|
||||
val answerRemoved = awaitItem()
|
||||
Truth.assertThat(answerRemoved.answers.size).isEqualTo(2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `set question sets the question`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
initial.eventSink(CreatePollEvents.SetQuestion("A question?"))
|
||||
val questionSet = awaitItem()
|
||||
Truth.assertThat(questionSet.question).isEqualTo("A question?")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `set poll answer sets the given poll answer`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
initial.eventSink(CreatePollEvents.SetAnswer(0, "This is answer 1"))
|
||||
val answerSet = awaitItem()
|
||||
Truth.assertThat(answerSet.answers.first().text).isEqualTo("This is answer 1")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `set poll kind sets the poll kind`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
initial.eventSink(CreatePollEvents.SetPollKind(PollKind.Undisclosed))
|
||||
val kindSet = awaitItem()
|
||||
Truth.assertThat(kindSet.pollKind).isEqualTo(PollKind.Undisclosed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `can add options when between 2 and 20 and then no more`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
Truth.assertThat(initial.canAddAnswer).isEqualTo(true)
|
||||
repeat(17) {
|
||||
initial.eventSink(CreatePollEvents.AddAnswer)
|
||||
Truth.assertThat(awaitItem().canAddAnswer).isEqualTo(true)
|
||||
}
|
||||
initial.eventSink(CreatePollEvents.AddAnswer)
|
||||
Truth.assertThat(awaitItem().canAddAnswer).isEqualTo(false)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `can delete option if there are more than 2`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
Truth.assertThat(initial.answers.all { it.canDelete }).isEqualTo(false)
|
||||
initial.eventSink(CreatePollEvents.AddAnswer)
|
||||
Truth.assertThat(awaitItem().answers.all { it.canDelete }).isEqualTo(true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `option with more than 240 char is truncated`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
initial.eventSink(CreatePollEvents.SetAnswer(0, "A".repeat(241)))
|
||||
Truth.assertThat(awaitItem().answers.first().text.length).isEqualTo(240)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `navBack event calls navBack lambda`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
Truth.assertThat(navUpInvocationsCount).isEqualTo(0)
|
||||
initial.eventSink(CreatePollEvents.NavBack)
|
||||
Truth.assertThat(navUpInvocationsCount).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirm nav back with blank fields calls nav back lambda`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
Truth.assertThat(navUpInvocationsCount).isEqualTo(0)
|
||||
Truth.assertThat(initial.showConfirmation).isEqualTo(false)
|
||||
initial.eventSink(CreatePollEvents.ConfirmNavBack)
|
||||
Truth.assertThat(navUpInvocationsCount).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirm nav back with non blank fields shows confirmation dialog and sending hide hids it`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
initial.eventSink(CreatePollEvents.SetQuestion("Non blank"))
|
||||
Truth.assertThat(navUpInvocationsCount).isEqualTo(0)
|
||||
Truth.assertThat(awaitItem().showConfirmation).isEqualTo(false)
|
||||
initial.eventSink(CreatePollEvents.ConfirmNavBack)
|
||||
Truth.assertThat(navUpInvocationsCount).isEqualTo(0)
|
||||
Truth.assertThat(awaitItem().showConfirmation).isEqualTo(true)
|
||||
initial.eventSink(CreatePollEvents.HideConfirmation)
|
||||
Truth.assertThat(awaitItem().showConfirmation).isEqualTo(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ import io.element.android.features.preferences.api.PreferencesEntryPoint
|
|||
import io.element.android.features.preferences.impl.about.AboutNode
|
||||
import io.element.android.features.preferences.impl.analytics.AnalyticsSettingsNode
|
||||
import io.element.android.features.preferences.impl.developer.DeveloperSettingsNode
|
||||
import io.element.android.features.preferences.impl.developer.tracing.ConfigureTracingNode
|
||||
import io.element.android.features.preferences.impl.root.PreferencesRootNode
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
|
|
@ -60,6 +61,9 @@ class PreferencesFlowNode @AssistedInject constructor(
|
|||
@Parcelize
|
||||
data object DeveloperSettings : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object ConfigureTracing : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object AnalyticsSettings : NavTarget
|
||||
|
||||
|
|
@ -94,7 +98,15 @@ class PreferencesFlowNode @AssistedInject constructor(
|
|||
createNode<PreferencesRootNode>(buildContext, plugins = listOf(callback))
|
||||
}
|
||||
NavTarget.DeveloperSettings -> {
|
||||
createNode<DeveloperSettingsNode>(buildContext)
|
||||
val callback = object : DeveloperSettingsNode.Callback {
|
||||
override fun openConfigureTracing() {
|
||||
backstack.push(NavTarget.ConfigureTracing)
|
||||
}
|
||||
}
|
||||
createNode<DeveloperSettingsNode>(buildContext, listOf(callback))
|
||||
}
|
||||
NavTarget.ConfigureTracing -> {
|
||||
createNode<ConfigureTracingNode>(buildContext)
|
||||
}
|
||||
NavTarget.About -> {
|
||||
createNode<AboutNode>(buildContext)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import com.airbnb.android.showkase.models.Showkase
|
|||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
|
|
@ -37,6 +38,14 @@ class DeveloperSettingsNode @AssistedInject constructor(
|
|||
private val presenter: DeveloperSettingsPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun openConfigureTracing()
|
||||
}
|
||||
|
||||
private fun onOpenConfigureTracing() {
|
||||
plugins<Callback>().forEach { it.openConfigureTracing() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val activity = LocalContext.current as Activity
|
||||
|
|
@ -50,6 +59,7 @@ class DeveloperSettingsNode @AssistedInject constructor(
|
|||
state = state,
|
||||
modifier = modifier,
|
||||
onOpenShowkase = ::openShowkase,
|
||||
onOpenConfigureTracing = ::onOpenConfigureTracing,
|
||||
onBackPressed = ::navigateUp
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,8 @@ class DeveloperSettingsPresenter @Inject constructor(
|
|||
features,
|
||||
enabledFeatures,
|
||||
event.feature,
|
||||
event.isEnabled
|
||||
event.isEnabled,
|
||||
triggerClearCache = { handleEvents(DeveloperSettingsEvents.ClearCache) }
|
||||
)
|
||||
DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction)
|
||||
}
|
||||
|
|
@ -122,12 +123,17 @@ class DeveloperSettingsPresenter @Inject constructor(
|
|||
features: SnapshotStateMap<String, Feature>,
|
||||
enabledFeatures: SnapshotStateMap<String, Boolean>,
|
||||
featureUiModel: FeatureUiModel,
|
||||
enabled: Boolean
|
||||
enabled: Boolean,
|
||||
triggerClearCache: () -> Unit,
|
||||
) = launch {
|
||||
val feature = features[featureUiModel.key] ?: return@launch
|
||||
if (featureFlagService.setFeatureEnabled(feature, enabled)) {
|
||||
enabledFeatures[featureUiModel.key] = enabled
|
||||
}
|
||||
|
||||
if (featureUiModel.key == FeatureFlags.UseEncryptionSync.key) {
|
||||
triggerClearCache()
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.computeCacheSize(cacheSize: MutableState<Async<String>>) = launch {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||
fun DeveloperSettingsView(
|
||||
state: DeveloperSettingsState,
|
||||
onOpenShowkase: () -> Unit,
|
||||
onOpenConfigureTracing: () -> Unit,
|
||||
onBackPressed: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -47,6 +48,12 @@ fun DeveloperSettingsView(
|
|||
PreferenceCategory(title = "Feature flags") {
|
||||
FeatureListContent(state)
|
||||
}
|
||||
PreferenceCategory(title = "Rust SDK") {
|
||||
PreferenceText(
|
||||
title = "Configure tracing",
|
||||
onClick = onOpenConfigureTracing,
|
||||
)
|
||||
}
|
||||
PreferenceCategory(title = "Showkase") {
|
||||
PreferenceText(
|
||||
title = "Open Showkase browser",
|
||||
|
|
@ -109,6 +116,7 @@ private fun ContentToPreview(state: DeveloperSettingsState) {
|
|||
DeveloperSettingsView(
|
||||
state = state,
|
||||
onOpenShowkase = {},
|
||||
onOpenConfigureTracing = {},
|
||||
onBackPressed = {}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.preferences.impl.developer.tracing
|
||||
|
||||
import io.element.android.libraries.matrix.api.tracing.LogLevel
|
||||
import io.element.android.libraries.matrix.api.tracing.Target
|
||||
|
||||
sealed interface ConfigureTracingEvents {
|
||||
data class UpdateFilter(val target: Target, val logLevel: LogLevel) : ConfigureTracingEvents
|
||||
data object ResetFilters : ConfigureTracingEvents
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* 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.preferences.impl.developer.tracing
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class ConfigureTracingNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: ConfigureTracingPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
ConfigureTracingView(
|
||||
state = state,
|
||||
onBackPressed = ::navigateUp,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* 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.preferences.impl.developer.tracing
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import javax.inject.Inject
|
||||
|
||||
class ConfigureTracingPresenter @Inject constructor(
|
||||
private val tracingConfigurationStore: TracingConfigurationStore,
|
||||
private val targetLogLevelMapBuilder: TargetLogLevelMapBuilder,
|
||||
) : Presenter<ConfigureTracingState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): ConfigureTracingState {
|
||||
val modifiedMap = remember { mutableStateOf(targetLogLevelMapBuilder.getCurrentMap()) }
|
||||
|
||||
fun handleEvents(event: ConfigureTracingEvents) {
|
||||
when (event) {
|
||||
is ConfigureTracingEvents.UpdateFilter -> {
|
||||
modifiedMap.value = modifiedMap.value.toMutableMap()
|
||||
.apply { this[event.target] = event.logLevel }
|
||||
tracingConfigurationStore.storeLogLevel(event.target, event.logLevel)
|
||||
}
|
||||
ConfigureTracingEvents.ResetFilters -> {
|
||||
modifiedMap.value = targetLogLevelMapBuilder.getDefaultMap()
|
||||
tracingConfigurationStore.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ConfigureTracingState(
|
||||
targetsToLogLevel = modifiedMap.value.toImmutableMap(),
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.preferences.impl.developer.tracing
|
||||
|
||||
import io.element.android.libraries.matrix.api.tracing.LogLevel
|
||||
import io.element.android.libraries.matrix.api.tracing.Target
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
|
||||
data class ConfigureTracingState(
|
||||
val targetsToLogLevel: ImmutableMap<Target, LogLevel>,
|
||||
val eventSink: (ConfigureTracingEvents) -> Unit
|
||||
)
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.preferences.impl.developer.tracing
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.matrix.api.tracing.LogLevel
|
||||
import io.element.android.libraries.matrix.api.tracing.Target
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
|
||||
open class ConfigureTracingStateProvider : PreviewParameterProvider<ConfigureTracingState> {
|
||||
override val values: Sequence<ConfigureTracingState>
|
||||
get() = sequenceOf(
|
||||
aConfigureTracingState(),
|
||||
)
|
||||
}
|
||||
|
||||
fun aConfigureTracingState() = ConfigureTracingState(
|
||||
targetsToLogLevel = persistentMapOf(
|
||||
Target.COMMON to LogLevel.INFO,
|
||||
Target.MATRIX_SDK_FFI to LogLevel.WARN,
|
||||
Target.MATRIX_SDK_BASE_SLIDING_SYNC to LogLevel.ERROR,
|
||||
),
|
||||
eventSink = {}
|
||||
)
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
/*
|
||||
* 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.preferences.impl.developer.tracing
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.ArrowDropUp
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
|
||||
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.tracing.LogLevel
|
||||
import io.element.android.libraries.matrix.api.tracing.Target
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConfigureTracingView(
|
||||
state: ConfigureTracingState,
|
||||
onBackPressed: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
Scaffold(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.imePadding(),
|
||||
contentWindowInsets = WindowInsets.statusBars,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
BackButton(onClick = onBackPressed)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Configure tracing",
|
||||
style = ElementTheme.typography.aliasScreenTitle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { showMenu = !showMenu }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
tint = ElementTheme.materialColors.secondary,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
showMenu = false
|
||||
state.eventSink.invoke(ConfigureTracingEvents.ResetFilters)
|
||||
},
|
||||
text = { Text("Reset to default") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Outlined.Delete,
|
||||
tint = ElementTheme.materialColors.secondary,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(it)
|
||||
.consumeWindowInsets(it)
|
||||
.verticalScroll(state = rememberScrollState())
|
||||
) {
|
||||
CrateListContent(state)
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = "Kill and restart the app for the change to take effect.",
|
||||
style = ElementTheme.typography.fontHeadingSmMedium,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CrateListContent(
|
||||
state: ConfigureTracingState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
fun onLogLevelChange(target: Target, logLevel: LogLevel) {
|
||||
state.eventSink(ConfigureTracingEvents.UpdateFilter(target, logLevel))
|
||||
}
|
||||
|
||||
TargetAndLogLevelListView(
|
||||
modifier = modifier,
|
||||
data = state.targetsToLogLevel,
|
||||
onLogLevelChange = ::onLogLevelChange,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TargetAndLogLevelListView(
|
||||
data: ImmutableMap<Target, LogLevel>,
|
||||
onLogLevelChange: (Target, LogLevel) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
data.forEach { item ->
|
||||
fun onLogLevelChange(logLevel: LogLevel) {
|
||||
onLogLevelChange(item.key, logLevel)
|
||||
}
|
||||
|
||||
TargetAndLogLevelView(
|
||||
target = item.key,
|
||||
logLevel = item.value,
|
||||
onLogLevelChange = ::onLogLevelChange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TargetAndLogLevelView(
|
||||
target: Target,
|
||||
logLevel: LogLevel,
|
||||
onLogLevelChange: (LogLevel) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
headlineContent = { Text(text = target.filter.takeIf { it.isNotEmpty() } ?: "(common)") },
|
||||
trailingContent = ListItemContent.Custom {
|
||||
LogLevelDropdownMenu(
|
||||
logLevel = logLevel,
|
||||
onLogLevelChange = onLogLevelChange,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LogLevelDropdownMenu(
|
||||
logLevel: LogLevel,
|
||||
onLogLevelChange: (LogLevel) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box(modifier = modifier) {
|
||||
DropdownMenuItem(
|
||||
modifier = Modifier.widthIn(max = 120.dp),
|
||||
text = { Text(text = logLevel.filter) },
|
||||
onClick = { expanded = !expanded },
|
||||
trailingIcon = {
|
||||
if (expanded) {
|
||||
Icon(Icons.Default.ArrowDropUp, contentDescription = null)
|
||||
} else {
|
||||
Icon(Icons.Default.ArrowDropDown, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
LogLevel.values().forEach { logLevel ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(text = logLevel.filter)
|
||||
},
|
||||
onClick = {
|
||||
expanded = false
|
||||
onLogLevelChange(logLevel)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
internal fun ConfigureTracingViewPreview(
|
||||
@PreviewParameter(ConfigureTracingStateProvider::class) state: ConfigureTracingState
|
||||
) = ElementPreview {
|
||||
ConfigureTracingView(
|
||||
state = state,
|
||||
onBackPressed = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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.preferences.impl.developer.tracing
|
||||
|
||||
import io.element.android.libraries.matrix.api.tracing.LogLevel
|
||||
import io.element.android.libraries.matrix.api.tracing.Target
|
||||
import io.element.android.libraries.matrix.api.tracing.TracingFilterConfigurations
|
||||
import javax.inject.Inject
|
||||
|
||||
class TargetLogLevelMapBuilder @Inject constructor(
|
||||
private val tracingConfigurationStore: TracingConfigurationStore,
|
||||
) {
|
||||
private val defaultConfig = TracingFilterConfigurations.debug
|
||||
|
||||
fun getDefaultMap(): Map<Target, LogLevel> {
|
||||
return Target.entries.associateWith { target ->
|
||||
defaultConfig.getLogLevel(target)
|
||||
?: LogLevel.INFO
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentMap(): Map<Target, LogLevel> {
|
||||
return Target.entries.associateWith { target ->
|
||||
tracingConfigurationStore.getLogLevel(target)
|
||||
?: defaultConfig.getLogLevel(target)
|
||||
?: LogLevel.INFO
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.preferences.impl.developer.tracing
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.DefaultPreferences
|
||||
import io.element.android.libraries.matrix.api.tracing.LogLevel
|
||||
import io.element.android.libraries.matrix.api.tracing.Target
|
||||
import javax.inject.Inject
|
||||
|
||||
interface TracingConfigurationStore {
|
||||
fun getLogLevel(target: Target): LogLevel?
|
||||
fun storeLogLevel(target: Target, logLevel: LogLevel)
|
||||
fun reset()
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class SharedPrefTracingConfigurationStore @Inject constructor(
|
||||
@DefaultPreferences private val sharedPreferences: SharedPreferences
|
||||
) : TracingConfigurationStore {
|
||||
override fun getLogLevel(target: Target): LogLevel? {
|
||||
return sharedPreferences.getString("$KEY_PREFIX${target.name}", null)
|
||||
?.let { LogLevel.valueOf(it) }
|
||||
}
|
||||
|
||||
override fun storeLogLevel(target: Target, logLevel: LogLevel) {
|
||||
sharedPreferences.edit {
|
||||
putString("$KEY_PREFIX${target.name}", logLevel.name)
|
||||
}
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
sharedPreferences.edit {
|
||||
sharedPreferences.all.keys.filter { it.startsWith(KEY_PREFIX) }.forEach {
|
||||
remove(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_PREFIX = "tracing_log_level_"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* 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.preferences.impl.developer.tracing
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.tracing.LogLevel
|
||||
import io.element.android.libraries.matrix.api.tracing.Target
|
||||
import io.element.android.tests.testutils.waitForPredicate
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class ConfigureTracingPresenterTest {
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val store = InMemoryTracingConfigurationStore()
|
||||
val presenter = ConfigureTracingPresenter(
|
||||
store,
|
||||
TargetLogLevelMapBuilder(store),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.targetsToLogLevel).isNotEmpty()
|
||||
assertThat(initialState.targetsToLogLevel[Target.MATRIX_SDK_CRYPTO]).isEqualTo(LogLevel.DEBUG)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - store is taken into account`() = runTest {
|
||||
val store = InMemoryTracingConfigurationStore()
|
||||
store.givenLogLevel(LogLevel.ERROR)
|
||||
val presenter = ConfigureTracingPresenter(
|
||||
store,
|
||||
TargetLogLevelMapBuilder(store),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.targetsToLogLevel).isNotEmpty()
|
||||
assertThat(initialState.targetsToLogLevel[Target.MATRIX_SDK_CRYPTO]).isEqualTo(LogLevel.ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - change a value`() = runTest {
|
||||
val store = InMemoryTracingConfigurationStore()
|
||||
val presenter = ConfigureTracingPresenter(
|
||||
store,
|
||||
TargetLogLevelMapBuilder(store),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.targetsToLogLevel[Target.MATRIX_SDK_CRYPTO]).isEqualTo(LogLevel.DEBUG)
|
||||
initialState.eventSink.invoke(ConfigureTracingEvents.UpdateFilter(Target.MATRIX_SDK_CRYPTO, LogLevel.WARN))
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.targetsToLogLevel[Target.MATRIX_SDK_CRYPTO]).isEqualTo(LogLevel.WARN)
|
||||
waitForPredicate { store.hasStoreLogLevelBeenCalled }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - reset`() = runTest {
|
||||
val store = InMemoryTracingConfigurationStore()
|
||||
val presenter = ConfigureTracingPresenter(
|
||||
store,
|
||||
TargetLogLevelMapBuilder(store),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.targetsToLogLevel[Target.MATRIX_SDK_CRYPTO]).isEqualTo(LogLevel.DEBUG)
|
||||
initialState.eventSink.invoke(ConfigureTracingEvents.UpdateFilter(Target.MATRIX_SDK_CRYPTO, LogLevel.WARN))
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.targetsToLogLevel[Target.MATRIX_SDK_CRYPTO]).isEqualTo(LogLevel.WARN)
|
||||
waitForPredicate { store.hasStoreLogLevelBeenCalled }
|
||||
finalState.eventSink.invoke(ConfigureTracingEvents.ResetFilters)
|
||||
val resetState = awaitItem()
|
||||
assertThat(resetState.targetsToLogLevel[Target.MATRIX_SDK_CRYPTO]).isEqualTo(LogLevel.DEBUG)
|
||||
waitForPredicate { store.hasResetBeenCalled }
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue