Merge branch 'release/0.1.6' into main

This commit is contained in:
Benoit Marty 2023-09-04 16:13:51 +02:00
commit fc40f69857
174 changed files with 3268 additions and 498 deletions

View file

@ -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

View file

@ -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):

View file

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

View file

@ -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

View file

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

View file

@ -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 }}

View file

@ -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

View file

@ -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
View file

@ -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>

View 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

View file

@ -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

View file

@ -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)
========================================

View file

@ -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:

View file

@ -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)

View file

@ -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()
}

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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
)

View file

@ -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(),
)
}
}

View file

@ -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)
}
}
}

View file

@ -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")
}
}

View 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.

View file

@ -0,0 +1,2 @@
Main changes in this version: bugfixes.
Full changelog: https://github.com/vector-im/element-x-android/releases

View file

@ -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,
)

View file

@ -28,5 +28,6 @@ open class AnalyticsPreferencesStateProvider : PreviewParameterProvider<Analytic
fun aAnalyticsPreferencesState() = AnalyticsPreferencesState(
applicationName = "Element X",
isEnabled = false,
policyUrl = "https://element.io",
eventSink = {}
)

View file

@ -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,
)
}

View file

@ -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,
)
)
}
}

View file

@ -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
)
}

View file

@ -39,6 +39,7 @@ class AnalyticsPreferencesPresenterTest {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isEnabled).isTrue()
assertThat(initialState.policyUrl).isNotEmpty()
}
}

View file

@ -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 {

View file

@ -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)

View file

@ -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)
}
}
}

View file

@ -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,
)
}

View file

@ -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

View file

@ -67,7 +67,7 @@ fun aMessagesState() = MessagesState(
),
actionListState = anActionListState(),
customReactionState = CustomReactionState(
selectedEventId = null,
target = CustomReactionState.Target.None,
eventSink = {},
selectedEmoji = persistentSetOf(),
),

View file

@ -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 = {},
)
}

View file

@ -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

View file

@ -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,
)
}

View file

@ -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)
}

View file

@ -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 = {},
)
}

View file

@ -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
}

View file

@ -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
)

View file

@ -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
) {

View file

@ -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 = {},
)

View file

@ -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 = {},
)
}

View file

@ -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
}

View file

@ -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
}
}
}

View file

@ -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 = {},
)
}

View file

@ -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 = { _, _ -> },
)
}

View file

@ -70,6 +70,7 @@ fun TimelineItemStateEventRow(
onClick = onClick,
onLongClick = onLongClick,
extraPadding = noExtraPadding,
onPollAnswerSelected = { _, _ -> error("Polls are not supported in state events") },
modifier = Modifier.defaultTimelineContentPadding()
)
}

View file

@ -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(),
)
}
}

View file

@ -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
}

View file

@ -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
)
}
}

View file

@ -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
}
}

View file

@ -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()
}

View file

@ -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("😀", "😄", "😃")
)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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,
)
}

View file

@ -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 = { _, _ -> },
)
}

View file

@ -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
)
}
}
}

View file

@ -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
}

View file

@ -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() }
}
}

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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 {

View file

@ -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(),
)
}

View file

@ -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)

View file

@ -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(

View file

@ -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))

View file

@ -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
)
}
}

View file

@ -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)
}
}

View file

@ -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())
}

View file

@ -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
)
}
}

View file

@ -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 = { _, _ -> },
)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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(),
)
}
}

View file

@ -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
}

View file

@ -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,
)
}
}

View file

@ -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
}
)
}
)

View file

@ -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,
)

View file

@ -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,
)
)
}

View file

@ -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,
)

View file

@ -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)
}
}

View 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>

View file

@ -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)
}
}
}

View file

@ -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)

View file

@ -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
)
}

View file

@ -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 {

View file

@ -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 = {}
)
}

View file

@ -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
}

View file

@ -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
)
}
}

View file

@ -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
)
}
}

View file

@ -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
)

View file

@ -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 = {}
)

View file

@ -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 = {},
)
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.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
}
}
}

View file

@ -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_"
}
}

View file

@ -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