Merge branch 'develop' of https://github.com/vector-im/element-x-android into yostyle/notifications_global_settings
This commit is contained in:
commit
5e2ec8b504
315 changed files with 3724 additions and 1216 deletions
|
|
@ -21,5 +21,6 @@ import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
|||
data class AnalyticsPreferencesState(
|
||||
val applicationName: String,
|
||||
val isEnabled: Boolean,
|
||||
val policyUrl: String,
|
||||
val eventSink: (AnalyticsOptInEvents) -> Unit,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -28,5 +28,6 @@ open class AnalyticsPreferencesStateProvider : PreviewParameterProvider<Analytic
|
|||
fun aAnalyticsPreferencesState() = AnalyticsPreferencesState(
|
||||
applicationName = "Element X",
|
||||
isEnabled = false,
|
||||
policyUrl = "https://element.io",
|
||||
eventSink = {}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,22 +16,21 @@
|
|||
|
||||
package io.element.android.features.analytics.api.preferences
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
|
||||
import io.element.android.libraries.designsystem.components.LINK_TAG
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.theme.LinkColor
|
||||
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
|
|
@ -43,40 +42,33 @@ fun AnalyticsPreferencesView(
|
|||
state.eventSink(AnalyticsOptInEvents.EnableAnalytics(isEnabled = isEnabled))
|
||||
}
|
||||
|
||||
val firstPart = stringResource(id = CommonStrings.screen_analytics_settings_help_us_improve, state.applicationName)
|
||||
val secondPart = buildAnnotatedStringWithColoredPart(
|
||||
val supportingText = stringResource(
|
||||
id = CommonStrings.screen_analytics_settings_help_us_improve,
|
||||
state.applicationName
|
||||
)
|
||||
val linkText = buildAnnotatedStringWithStyledPart(
|
||||
CommonStrings.screen_analytics_settings_read_terms,
|
||||
CommonStrings.screen_analytics_settings_read_terms_content_link
|
||||
)
|
||||
val subtitle = "$firstPart\n\n$secondPart"
|
||||
|
||||
PreferenceSwitch(
|
||||
modifier = modifier,
|
||||
title = stringResource(id = CommonStrings.screen_analytics_settings_share_data),
|
||||
subtitle = subtitle,
|
||||
isChecked = state.isEnabled,
|
||||
onCheckedChange = ::onEnabledChanged,
|
||||
switchAlignment = Alignment.Top,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun buildAnnotatedStringWithColoredPart(
|
||||
@StringRes fullTextRes: Int,
|
||||
@StringRes coloredTextRes: Int,
|
||||
color: Color = LinkColor,
|
||||
underline: Boolean = true,
|
||||
) = buildAnnotatedString {
|
||||
val coloredPart = stringResource(coloredTextRes)
|
||||
val fullText = stringResource(fullTextRes, coloredPart)
|
||||
val startIndex = fullText.indexOf(coloredPart)
|
||||
append(fullText)
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
color = color,
|
||||
textDecoration = if (underline) TextDecoration.Underline else null
|
||||
), start = startIndex, end = startIndex + coloredPart.length
|
||||
CommonStrings.screen_analytics_settings_read_terms_content_link,
|
||||
tagAndLink = LINK_TAG to state.policyUrl,
|
||||
)
|
||||
Column(modifier) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(stringResource(id = CommonStrings.screen_analytics_settings_share_data))
|
||||
},
|
||||
supportingContent = {
|
||||
Text(supportingText)
|
||||
},
|
||||
leadingContent = null,
|
||||
trailingContent = ListItemContent.Switch(
|
||||
checked = state.isEnabled,
|
||||
),
|
||||
onClick = {
|
||||
onEnabledChanged(!state.isEnabled)
|
||||
}
|
||||
)
|
||||
ListSupportingText(annotatedString = linkText)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
|
@ -91,5 +83,7 @@ internal fun AnalyticsPreferencesViewDarkPreview(@PreviewParameter(AnalyticsPref
|
|||
|
||||
@Composable
|
||||
private fun ContentToPreview(state: AnalyticsPreferencesState) {
|
||||
AnalyticsPreferencesView(state)
|
||||
AnalyticsPreferencesView(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,5 @@ dependencies {
|
|||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.mockk)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.features.analytics.test)
|
||||
testImplementation(projects.features.analytics.impl)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ package io.element.android.features.analytics.impl
|
|||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
|
@ -28,7 +27,7 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Poll
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
|
|
@ -37,7 +36,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.BiasAlignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
|
@ -45,6 +43,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
||||
import io.element.android.features.analytics.api.Config
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem
|
||||
|
|
@ -56,7 +55,6 @@ import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithSt
|
|||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.ButtonSize
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
|
||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||
|
|
@ -98,6 +96,8 @@ fun AnalyticsOptInView(
|
|||
)
|
||||
}
|
||||
|
||||
private const val LINK_TAG = "link"
|
||||
|
||||
@Composable
|
||||
private fun AnalyticsOptInHeader(
|
||||
state: AnalyticsOptInState,
|
||||
|
|
@ -114,21 +114,29 @@ private fun AnalyticsOptInHeader(
|
|||
subTitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
|
||||
iconImageVector = Icons.Filled.Poll
|
||||
)
|
||||
Text(
|
||||
text = buildAnnotatedStringWithStyledPart(
|
||||
R.string.screen_analytics_prompt_read_terms,
|
||||
R.string.screen_analytics_prompt_read_terms_content_link,
|
||||
color = Color.Unspecified,
|
||||
underline = false,
|
||||
bold = true,
|
||||
),
|
||||
val text = buildAnnotatedStringWithStyledPart(
|
||||
R.string.screen_analytics_prompt_read_terms,
|
||||
R.string.screen_analytics_prompt_read_terms_content_link,
|
||||
color = Color.Unspecified,
|
||||
underline = false,
|
||||
bold = true,
|
||||
tagAndLink = LINK_TAG to Config.POLICY_LINK,
|
||||
)
|
||||
ClickableText(
|
||||
text = text,
|
||||
onClick = {
|
||||
text
|
||||
.getStringAnnotations(LINK_TAG, it, it)
|
||||
.firstOrNull()
|
||||
?.let { _ -> onClickTerms() }
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(shape = RoundedCornerShape(8.dp))
|
||||
.clickable { onClickTerms() }
|
||||
.padding(8.dp),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
style = ElementTheme.typography.fontBodyMdRegular
|
||||
.copy(
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.compose.runtime.collectAsState
|
|||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
||||
import io.element.android.features.analytics.api.Config
|
||||
import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesPresenter
|
||||
import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesState
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
|
|
@ -51,6 +52,7 @@ class DefaultAnalyticsPreferencesPresenter @Inject constructor(
|
|||
return AnalyticsPreferencesState(
|
||||
applicationName = buildMeta.applicationName,
|
||||
isEnabled = isEnabled.value,
|
||||
policyUrl = Config.POLICY_LINK,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,6 @@
|
|||
<string name="screen_analytics_prompt_read_terms">"Du kannst alle unsere Nutzerbedingungen %1$s lesen."</string>
|
||||
<string name="screen_analytics_prompt_read_terms_content_link">"hier"</string>
|
||||
<string name="screen_analytics_prompt_settings">"Du kannst dies jederzeit deaktivieren"</string>
|
||||
<string name="screen_analytics_prompt_third_party_sharing">"Wir geben "<b>"keine"</b>" Informationen an Dritte weiter"</string>
|
||||
<string name="screen_analytics_prompt_third_party_sharing">"Wir geben deine Daten nicht an Dritte weiter"</string>
|
||||
<string name="screen_analytics_prompt_title">"Hilf uns, %1$s zu verbessern"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ import app.cash.molecule.moleculeFlow
|
|||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
||||
import io.element.android.features.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ import app.cash.molecule.moleculeFlow
|
|||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
||||
import io.element.android.features.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
|
|
@ -39,6 +39,7 @@ class AnalyticsPreferencesPresenterTest {
|
|||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.isEnabled).isTrue()
|
||||
assertThat(initialState.policyUrl).isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.analytics.test
|
||||
|
||||
import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
|
||||
import im.vector.app.features.analytics.itf.VectorAnalyticsScreen
|
||||
import im.vector.app.features.analytics.plan.UserProperties
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analyticsproviders.api.AnalyticsProvider
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class FakeAnalyticsService(
|
||||
isEnabled: Boolean = false,
|
||||
didAskUserConsent: Boolean = false
|
||||
): AnalyticsService {
|
||||
|
||||
private val isEnabledFlow = MutableStateFlow(isEnabled)
|
||||
private val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
|
||||
val capturedEvents = mutableListOf<VectorAnalyticsEvent>()
|
||||
|
||||
override fun getAvailableAnalyticsProviders(): Set<AnalyticsProvider> = emptySet()
|
||||
|
||||
override fun getUserConsent(): Flow<Boolean> = isEnabledFlow
|
||||
|
||||
override suspend fun setUserConsent(userConsent: Boolean) {
|
||||
isEnabledFlow.value = userConsent
|
||||
}
|
||||
|
||||
override fun didAskUserConsent(): Flow<Boolean> = didAskUserConsentFlow
|
||||
|
||||
override suspend fun setDidAskUserConsent() {
|
||||
didAskUserConsentFlow.value = true
|
||||
}
|
||||
|
||||
override fun getAnalyticsId(): Flow<String> = MutableStateFlow("")
|
||||
|
||||
override suspend fun setAnalyticsId(analyticsId: String) {
|
||||
}
|
||||
|
||||
override suspend fun onSignOut() {
|
||||
}
|
||||
|
||||
override fun capture(event: VectorAnalyticsEvent) {
|
||||
capturedEvents += event
|
||||
}
|
||||
|
||||
override fun screen(screen: VectorAnalyticsScreen) {
|
||||
}
|
||||
|
||||
override fun updateUserProperties(userProperties: UserProperties) {
|
||||
}
|
||||
|
||||
override fun trackError(throwable: Throwable) {
|
||||
}
|
||||
|
||||
override suspend fun reset() {
|
||||
didAskUserConsentFlow.value = false
|
||||
}
|
||||
}
|
||||
|
|
@ -60,7 +60,7 @@ dependencies {
|
|||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(projects.features.analytics.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.mediapickers.test)
|
||||
testImplementation(projects.libraries.mediaupload.test)
|
||||
|
|
|
|||
|
|
@ -63,10 +63,10 @@ class ConfigureRoomFlowNode @AssistedInject constructor(
|
|||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object Root : NavTarget
|
||||
data object Root : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object ConfigureRoom : NavTarget
|
||||
data object ConfigureRoom : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
|
|
|
|||
|
|
@ -54,10 +54,10 @@ class CreateRoomFlowNode @AssistedInject constructor(
|
|||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object Root : NavTarget
|
||||
data object Root : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object NewRoom : NavTarget
|
||||
data object NewRoom : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
|
|
|
|||
|
|
@ -27,5 +27,5 @@ sealed interface ConfigureRoomEvents {
|
|||
data class RemoveFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents
|
||||
data class CreateRoom(val config: CreateRoomConfig) : ConfigureRoomEvents
|
||||
data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents
|
||||
object CancelCreateRoom : ConfigureRoomEvents
|
||||
data object CancelCreateRoom : ConfigureRoomEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,5 +20,5 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
|
|||
|
||||
sealed interface CreateRoomRootEvents {
|
||||
data class StartDM(val matrixUser: MatrixUser) : CreateRoomRootEvents
|
||||
object CancelStartDM : CreateRoomRootEvents
|
||||
data object CancelStartDM : CreateRoomRootEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import app.cash.molecule.moleculeFlow
|
|||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.CreatedRoom
|
||||
import io.element.android.features.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.features.createroom.impl.CreateRoomConfig
|
||||
import io.element.android.features.createroom.impl.CreateRoomDataStore
|
||||
import io.element.android.features.createroom.impl.userlist.UserListDataStore
|
||||
|
|
@ -38,6 +37,7 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
|
|||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import app.cash.molecule.moleculeFlow
|
|||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.CreatedRoom
|
||||
import io.element.android.features.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
|
||||
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
|
||||
import io.element.android.features.createroom.impl.userlist.UserListDataStore
|
||||
|
|
@ -35,6 +34,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
|
|||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.usersearch.test.FakeUserRepository
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ dependencies {
|
|||
implementation(projects.anvilannotations)
|
||||
anvil(projects.anvilcodegen)
|
||||
api(projects.features.ftue.api)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
|
|
@ -49,7 +50,7 @@ dependencies {
|
|||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.features.analytics.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import dagger.assisted.AssistedInject
|
|||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.analytics.api.AnalyticsEntryPoint
|
||||
import io.element.android.features.ftue.api.FtueEntryPoint
|
||||
import io.element.android.features.ftue.impl.migration.MigrationScreenNode
|
||||
import io.element.android.features.ftue.impl.state.DefaultFtueState
|
||||
import io.element.android.features.ftue.impl.state.FtueStep
|
||||
import io.element.android.features.ftue.impl.welcome.WelcomeNode
|
||||
|
|
@ -41,6 +42,7 @@ 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.AppScope
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
|
@ -50,7 +52,7 @@ import kotlinx.coroutines.flow.onEach
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
@ContributesNode(SessionScope::class)
|
||||
class FtueFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
|
|
@ -69,13 +71,16 @@ class FtueFlowNode @AssistedInject constructor(
|
|||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object Placeholder : NavTarget
|
||||
data object Placeholder : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object WelcomeScreen : NavTarget
|
||||
data object MigrationScreen : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object AnalyticsOptIn : NavTarget
|
||||
data object WelcomeScreen : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object AnalyticsOptIn : NavTarget
|
||||
}
|
||||
|
||||
private val callback = plugins.filterIsInstance<FtueEntryPoint.Callback>().firstOrNull()
|
||||
|
|
@ -102,6 +107,14 @@ class FtueFlowNode @AssistedInject constructor(
|
|||
NavTarget.Placeholder -> {
|
||||
createNode<PlaceholderNode>(buildContext)
|
||||
}
|
||||
NavTarget.MigrationScreen -> {
|
||||
val callback = object : MigrationScreenNode.Callback {
|
||||
override fun onMigrationFinished() {
|
||||
lifecycleScope.launch { moveToNextStep() }
|
||||
}
|
||||
}
|
||||
createNode<MigrationScreenNode>(buildContext, listOf(callback))
|
||||
}
|
||||
NavTarget.WelcomeScreen -> {
|
||||
val callback = object : WelcomeNode.Callback {
|
||||
override fun onContinueClicked() {
|
||||
|
|
@ -117,12 +130,15 @@ class FtueFlowNode @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun moveToNextStep() {
|
||||
private fun moveToNextStep() {
|
||||
when (ftueState.getNextStep()) {
|
||||
is FtueStep.WelcomeScreen -> {
|
||||
FtueStep.MigrationScreen -> {
|
||||
backstack.newRoot(NavTarget.MigrationScreen)
|
||||
}
|
||||
FtueStep.WelcomeScreen -> {
|
||||
backstack.newRoot(NavTarget.WelcomeScreen)
|
||||
}
|
||||
is FtueStep.AnalyticsOptIn -> {
|
||||
FtueStep.AnalyticsOptIn -> {
|
||||
backstack.replace(NavTarget.AnalyticsOptIn)
|
||||
}
|
||||
null -> callback?.onFtueFlowFinished()
|
||||
|
|
|
|||
|
|
@ -14,57 +14,40 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.poll.impl
|
||||
package io.element.android.features.ftue.impl.migration
|
||||
|
||||
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(
|
||||
class MigrationScreenNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
) : BackstackNode<PollFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Root,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
) {
|
||||
private val presenter: MigrationScreenPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object Root : NavTarget
|
||||
interface Callback : Plugin {
|
||||
fun onMigrationFinished()
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Root -> {
|
||||
createNode(buildContext)
|
||||
}
|
||||
}
|
||||
private fun onMigrationFinished() {
|
||||
plugins.filterIsInstance<Callback>().forEach { it.onMigrationFinished() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Children(
|
||||
navModel = backstack,
|
||||
modifier = modifier,
|
||||
transitionHandler = rememberDefaultTransitionHandler(),
|
||||
val state = presenter.present()
|
||||
MigrationScreenView(
|
||||
state,
|
||||
onMigrationFinished = ::onMigrationFinished,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* 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.ftue.impl.migration
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import javax.inject.Inject
|
||||
|
||||
class MigrationScreenPresenter @Inject constructor(
|
||||
private val matrixClient: MatrixClient,
|
||||
private val migrationScreenStore: MigrationScreenStore,
|
||||
) : Presenter<MigrationScreenState> {
|
||||
@Composable
|
||||
override fun present(): MigrationScreenState {
|
||||
val roomListState by matrixClient.roomListService.state.collectAsState()
|
||||
if (roomListState == RoomListService.State.Running) {
|
||||
LaunchedEffect(Unit) {
|
||||
migrationScreenStore.setMigrationScreenShown(matrixClient.sessionId)
|
||||
}
|
||||
}
|
||||
return MigrationScreenState(
|
||||
isMigrating = roomListState != RoomListService.State.Running
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -13,16 +13,9 @@
|
|||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.analytics.test"
|
||||
}
|
||||
package io.element.android.features.ftue.impl.migration
|
||||
|
||||
dependencies {
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(libs.coroutines.core)
|
||||
}
|
||||
data class MigrationScreenState(
|
||||
val isMigrating: Boolean
|
||||
)
|
||||
|
|
@ -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.ftue.impl.migration
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
interface MigrationScreenStore {
|
||||
fun isMigrationScreenNeeded(sessionId: SessionId): Boolean
|
||||
fun setMigrationScreenShown(sessionId: SessionId)
|
||||
fun reset()
|
||||
}
|
||||
|
|
@ -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.ftue.impl.migration
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.features.ftue.impl.R
|
||||
import io.element.android.libraries.designsystem.atomic.pages.SunsetPage
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
|
||||
@Composable
|
||||
fun MigrationScreenView(
|
||||
migrationState: MigrationScreenState,
|
||||
onMigrationFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (migrationState.isMigrating.not()) {
|
||||
LaunchedEffect(Unit) {
|
||||
onMigrationFinished()
|
||||
}
|
||||
}
|
||||
SunsetPage(
|
||||
modifier = modifier,
|
||||
isLoading = true,
|
||||
title = stringResource(id = R.string.screen_migration_title),
|
||||
subtitle = stringResource(id = R.string.screen_migration_message),
|
||||
overallContent = {}
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
internal fun MigrationViewPreview() = ElementPreview {
|
||||
MigrationScreenView(
|
||||
migrationState = MigrationScreenState(isMigrating = true),
|
||||
onMigrationFinished = {})
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* 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.ftue.impl.migration
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.androidutils.hash.hash
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.DefaultPreferences
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class SharedPrefsMigrationScreenStore @Inject constructor(
|
||||
@DefaultPreferences private val sharedPreferences: SharedPreferences,
|
||||
) : MigrationScreenStore {
|
||||
|
||||
override fun isMigrationScreenNeeded(sessionId: SessionId): Boolean {
|
||||
return sharedPreferences.getBoolean(sessionId.toKey(), false).not()
|
||||
}
|
||||
|
||||
override fun setMigrationScreenShown(sessionId: SessionId) {
|
||||
sharedPreferences.edit().putBoolean(sessionId.toKey(), true).apply()
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
sharedPreferences.edit {
|
||||
sharedPreferences.all.keys
|
||||
.filter { it.startsWith(IS_MIGRATION_SCREEN_SHOWN_PREFIX) }
|
||||
.forEach {
|
||||
remove(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SessionId.toKey(): String {
|
||||
// Hash the sessionId to get rid of exotic char and take only the first 16 chars,
|
||||
// The risk of collision is not high.
|
||||
return IS_MIGRATION_SCREEN_SHOWN_PREFIX + value.hash().take(16)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val IS_MIGRATION_SCREEN_SHOWN_PREFIX = "is_migration_screen_shown_"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -19,8 +19,10 @@ package io.element.android.features.ftue.impl.state
|
|||
import androidx.annotation.VisibleForTesting
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.ftue.api.state.FtueState
|
||||
import io.element.android.features.ftue.impl.migration.MigrationScreenStore
|
||||
import io.element.android.features.ftue.impl.welcome.state.WelcomeScreenState
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -30,11 +32,13 @@ import kotlinx.coroutines.flow.onEach
|
|||
import kotlinx.coroutines.runBlocking
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultFtueState @Inject constructor(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val welcomeScreenState: WelcomeScreenState,
|
||||
private val migrationScreenStore: MigrationScreenStore,
|
||||
private val matrixClient: MatrixClient,
|
||||
) : FtueState {
|
||||
|
||||
override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete())
|
||||
|
|
@ -42,6 +46,7 @@ class DefaultFtueState @Inject constructor(
|
|||
override suspend fun reset() {
|
||||
welcomeScreenState.reset()
|
||||
analyticsService.reset()
|
||||
migrationScreenStore.reset()
|
||||
}
|
||||
|
||||
init {
|
||||
|
|
@ -52,7 +57,10 @@ class DefaultFtueState @Inject constructor(
|
|||
|
||||
fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
|
||||
when (currentStep) {
|
||||
null -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep(
|
||||
null -> if (shouldDisplayMigrationScreen()) FtueStep.MigrationScreen else getNextStep(
|
||||
FtueStep.MigrationScreen
|
||||
)
|
||||
FtueStep.MigrationScreen -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep(
|
||||
FtueStep.WelcomeScreen
|
||||
)
|
||||
FtueStep.WelcomeScreen -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep(
|
||||
|
|
@ -63,11 +71,16 @@ class DefaultFtueState @Inject constructor(
|
|||
|
||||
private fun isAnyStepIncomplete(): Boolean {
|
||||
return listOf(
|
||||
shouldDisplayMigrationScreen(),
|
||||
shouldDisplayWelcomeScreen(),
|
||||
needsAnalyticsOptIn()
|
||||
).any { it }
|
||||
}
|
||||
|
||||
private fun shouldDisplayMigrationScreen(): Boolean {
|
||||
return migrationScreenStore.isMigrationScreenNeeded(matrixClient.sessionId)
|
||||
}
|
||||
|
||||
private fun needsAnalyticsOptIn(): Boolean {
|
||||
// We need this function to not be suspend, so we need to load the value through runBlocking
|
||||
return runBlocking { analyticsService.didAskUserConsent().first().not() }
|
||||
|
|
@ -89,6 +102,7 @@ class DefaultFtueState @Inject constructor(
|
|||
}
|
||||
|
||||
sealed interface FtueStep {
|
||||
object WelcomeScreen : FtueStep
|
||||
object AnalyticsOptIn : FtueStep
|
||||
data object MigrationScreen : FtueStep
|
||||
data object WelcomeScreen : FtueStep
|
||||
data object AnalyticsOptIn : FtueStep
|
||||
}
|
||||
|
|
|
|||
11
features/ftue/impl/src/main/res/values-cs/translations.xml
Normal file
11
features/ftue/impl/src/main/res/values-cs/translations.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_migration_message">"Toto je jednorázový proces, děkujeme za čekání."</string>
|
||||
<string name="screen_migration_title">"Nastavení vašeho účtu"</string>
|
||||
<string name="screen_welcome_bullet_1">"Hovory, hlasování, vyhledávání a další budou přidány koncem tohoto roku."</string>
|
||||
<string name="screen_welcome_bullet_2">"Historie zpráv šifrovaných místností nebude v této aktualizaci k dispozici."</string>
|
||||
<string name="screen_welcome_bullet_3">"Rádi bychom se od vás dozvěděli, co si o tom myslíte, dejte nám vědět prostřednictvím stránky s nastavením."</string>
|
||||
<string name="screen_welcome_button">"Jdeme na to!"</string>
|
||||
<string name="screen_welcome_subtitle">"Zde je to, co potřebujete vědět:"</string>
|
||||
<string name="screen_welcome_title">"Vítá vás %1$s!"</string>
|
||||
</resources>
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_welcome_bullet_1">"Anrufe, Standortfreigabe, Suche und mehr werden später in diesem Jahr hinzugefügt."</string>
|
||||
<string name="screen_migration_message">"Dies ist ein einmaliger Vorgang, danke fürs Warten."</string>
|
||||
<string name="screen_migration_title">"Dein Konto einrichten"</string>
|
||||
<string name="screen_welcome_bullet_1">"Anrufe, Umfragen, Suche und mehr werden später in diesem Jahr hinzugefügt."</string>
|
||||
<string name="screen_welcome_bullet_2">"Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein."</string>
|
||||
<string name="screen_welcome_bullet_3">"Wir würden uns freuen, wenn du uns über die Einstellungsseite deine Meinung mitteilst."</string>
|
||||
<string name="screen_welcome_button">"Los geht\'s!"</string>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_migration_message">"Ce processus n’a besoin d’être fait qu’une seule fois, merci de patienter."</string>
|
||||
<string name="screen_migration_title">"Configuration de votre compte."</string>
|
||||
<string name="screen_welcome_bullet_2">"L’historique des messages pour les salons chiffrés ne sera pas disponible dans cette mise à jour."</string>
|
||||
<string name="screen_welcome_bullet_3">"Nous serions ravis d’avoir votre avis, n’hésitez pas à nous le partager via la page des paramètres."</string>
|
||||
<string name="screen_welcome_button">"C’est parti !"</string>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_migration_message">"Это одноразовый процесс, спасибо, что подождали."</string>
|
||||
<string name="screen_migration_title">"Настройка учетной записи."</string>
|
||||
<string name="screen_welcome_bullet_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string>
|
||||
<string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string>
|
||||
<string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_migration_message">"Ide o jednorazový proces, ďakujeme za trpezlivosť."</string>
|
||||
<string name="screen_migration_title">"Nastavenie vášho účtu."</string>
|
||||
<string name="screen_welcome_bullet_1">"Hovory, ankety, vyhľadávanie a ďalšie funkcie pribudnú neskôr v tomto roku."</string>
|
||||
<string name="screen_welcome_bullet_2">"História správ pre zašifrované miestnosti nebude v tejto aktualizácii k dispozícii."</string>
|
||||
<string name="screen_welcome_bullet_3">"Radi by sme od vás počuli, dajte nám vedieť, čo si myslíte, prostredníctvom stránky nastavení."</string>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_migration_title">"設定您的帳號"</string>
|
||||
<string name="screen_welcome_button">"開始吧!"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_migration_message">"This is a one time process, thanks for waiting."</string>
|
||||
<string name="screen_migration_title">"Setting up your account."</string>
|
||||
<string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string>
|
||||
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms won’t be available in this update."</string>
|
||||
<string name="screen_welcome_bullet_3">"We’d love to hear from you, let us know what you think via the settings page."</string>
|
||||
|
|
|
|||
|
|
@ -17,11 +17,16 @@
|
|||
package io.element.android.features.ftue.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.features.ftue.impl.migration.InMemoryMigrationScreenStore
|
||||
import io.element.android.features.ftue.impl.migration.MigrationScreenStore
|
||||
import io.element.android.features.ftue.impl.state.DefaultFtueState
|
||||
import io.element.android.features.ftue.impl.state.FtueStep
|
||||
import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
|
|
@ -45,12 +50,14 @@ class DefaultFtueStateTests {
|
|||
fun `given all checks being true, should display flow is false`() = runTest {
|
||||
val welcomeState = FakeWelcomeState()
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val migrationScreenStore = InMemoryMigrationScreenStore()
|
||||
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
|
||||
val state = createState(coroutineScope, welcomeState, analyticsService)
|
||||
val state = createState(coroutineScope, welcomeState, analyticsService, migrationScreenStore)
|
||||
|
||||
welcomeState.setWelcomeScreenShown()
|
||||
analyticsService.setDidAskUserConsent()
|
||||
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
|
||||
state.updateState()
|
||||
|
||||
assertThat(state.shouldDisplayFlow.value).isFalse()
|
||||
|
|
@ -63,16 +70,21 @@ class DefaultFtueStateTests {
|
|||
fun `traverse flow`() = runTest {
|
||||
val welcomeState = FakeWelcomeState()
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val migrationScreenStore = InMemoryMigrationScreenStore()
|
||||
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
|
||||
val state = createState(coroutineScope, welcomeState, analyticsService)
|
||||
val state = createState(coroutineScope, welcomeState, analyticsService, migrationScreenStore)
|
||||
val steps = mutableListOf<FtueStep?>()
|
||||
|
||||
// First step, welcome screen
|
||||
// First step, migration screen
|
||||
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
|
||||
|
||||
// Second step, welcome screen
|
||||
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||
welcomeState.setWelcomeScreenShown()
|
||||
|
||||
// Second step, analytics opt in
|
||||
// Third step, analytics opt in
|
||||
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||
analyticsService.setDidAskUserConsent()
|
||||
|
||||
|
|
@ -80,6 +92,7 @@ class DefaultFtueStateTests {
|
|||
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||
|
||||
assertThat(steps).containsExactly(
|
||||
FtueStep.MigrationScreen,
|
||||
FtueStep.WelcomeScreen,
|
||||
FtueStep.AnalyticsOptIn,
|
||||
null, // Final state
|
||||
|
|
@ -93,7 +106,16 @@ class DefaultFtueStateTests {
|
|||
fun `if a check for a step is true, start from the next one`() = runTest {
|
||||
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val state = createState(coroutineScope = coroutineScope, analyticsService = analyticsService)
|
||||
val migrationScreenStore = InMemoryMigrationScreenStore()
|
||||
|
||||
val state = createState(
|
||||
coroutineScope = coroutineScope,
|
||||
analyticsService = analyticsService,
|
||||
migrationScreenStore = migrationScreenStore,
|
||||
)
|
||||
|
||||
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
|
||||
assertThat(state.getNextStep()).isEqualTo(FtueStep.WelcomeScreen)
|
||||
|
||||
state.setWelcomeScreenShown()
|
||||
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
|
||||
|
|
@ -108,7 +130,14 @@ class DefaultFtueStateTests {
|
|||
private fun createState(
|
||||
coroutineScope: CoroutineScope,
|
||||
welcomeState: FakeWelcomeState = FakeWelcomeState(),
|
||||
analyticsService: AnalyticsService = FakeAnalyticsService()
|
||||
) = DefaultFtueState(coroutineScope, analyticsService, welcomeState)
|
||||
|
||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||
migrationScreenStore: MigrationScreenStore = InMemoryMigrationScreenStore(),
|
||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||
) = DefaultFtueState(
|
||||
coroutineScope = coroutineScope,
|
||||
analyticsService = analyticsService,
|
||||
welcomeScreenState = welcomeState,
|
||||
migrationScreenStore = migrationScreenStore,
|
||||
matrixClient = matrixClient,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.ftue.impl.migration
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
class InMemoryMigrationScreenStore : MigrationScreenStore {
|
||||
private val store = mutableMapOf<SessionId, Boolean>()
|
||||
|
||||
override fun isMigrationScreenNeeded(sessionId: SessionId): Boolean {
|
||||
// If store does not have key return true, else return the opposite of the value
|
||||
return store[sessionId]?.not() ?: true
|
||||
}
|
||||
|
||||
override fun setMigrationScreenShown(sessionId: SessionId) {
|
||||
store[sessionId] = true
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
store.clear()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* 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.ftue.impl.migration
|
||||
|
||||
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.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class MigrationScreenPresenterTest {
|
||||
@Test
|
||||
fun `present - initial`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.isMigrating).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - migration end`() = runTest {
|
||||
val matrixClient = FakeMatrixClient()
|
||||
val migrationScreenStore = InMemoryMigrationScreenStore()
|
||||
val presenter = createPresenter(matrixClient, migrationScreenStore)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.isMigrating).isTrue()
|
||||
assertThat(migrationScreenStore.isMigrationScreenNeeded(A_SESSION_ID)).isTrue()
|
||||
// Simulate room list loaded
|
||||
(matrixClient.roomListService as FakeRoomListService).postState(RoomListService.State.Running)
|
||||
val nextState = awaitItem()
|
||||
assertThat(nextState.isMigrating).isFalse()
|
||||
assertThat(migrationScreenStore.isMigrationScreenNeeded(A_SESSION_ID)).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPresenter(
|
||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||
migrationScreenStore: MigrationScreenStore = InMemoryMigrationScreenStore(),
|
||||
) = MigrationScreenPresenter(
|
||||
matrixClient,
|
||||
migrationScreenStore,
|
||||
)
|
||||
}
|
||||
|
|
@ -53,7 +53,7 @@ dependencies {
|
|||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.features.invitelist.test)
|
||||
testImplementation(projects.features.analytics.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,14 +19,12 @@ package io.element.android.features.invitelist.impl
|
|||
import io.element.android.features.invitelist.impl.model.InviteListInviteSummary
|
||||
|
||||
sealed interface InviteListEvents {
|
||||
|
||||
data class AcceptInvite(val invite: InviteListInviteSummary) : InviteListEvents
|
||||
data class DeclineInvite(val invite: InviteListInviteSummary) : InviteListEvents
|
||||
|
||||
object ConfirmDeclineInvite: InviteListEvents
|
||||
object CancelDeclineInvite: InviteListEvents
|
||||
|
||||
object DismissAcceptError: InviteListEvents
|
||||
object DismissDeclineError: InviteListEvents
|
||||
data object ConfirmDeclineInvite: InviteListEvents
|
||||
data object CancelDeclineInvite: InviteListEvents
|
||||
|
||||
data object DismissAcceptError: InviteListEvents
|
||||
data object DismissDeclineError: InviteListEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,6 @@ data class InviteListState(
|
|||
)
|
||||
|
||||
sealed interface InviteDeclineConfirmationDialog {
|
||||
object Hidden : InviteDeclineConfirmationDialog
|
||||
data object Hidden : InviteDeclineConfirmationDialog
|
||||
data class Visible(val isDirect: Boolean, val name: String) : InviteDeclineConfirmationDialog
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ 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.features.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.features.invitelist.api.SeenInvitesStore
|
||||
import io.element.android.features.invitelist.test.FakeSeenInvitesStore
|
||||
import io.element.android.libraries.architecture.Async
|
||||
|
|
@ -44,6 +43,7 @@ import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
|||
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
|
||||
import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
|||
|
||||
sealed interface LeaveRoomEvent {
|
||||
data class ShowConfirmation(val roomId: RoomId) : LeaveRoomEvent
|
||||
object HideConfirmation : LeaveRoomEvent
|
||||
data object HideConfirmation : LeaveRoomEvent
|
||||
data class LeaveRoom(val roomId: RoomId) : LeaveRoomEvent
|
||||
object HideError : LeaveRoomEvent
|
||||
data object HideError : LeaveRoomEvent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,19 +25,19 @@ data class LeaveRoomState(
|
|||
val eventSink: (LeaveRoomEvent) -> Unit = {},
|
||||
) {
|
||||
sealed interface Confirmation {
|
||||
object Hidden : Confirmation
|
||||
data object Hidden : Confirmation
|
||||
data class Generic(val roomId: RoomId) : Confirmation
|
||||
data class PrivateRoom(val roomId: RoomId) : Confirmation
|
||||
data class LastUserInRoom(val roomId: RoomId) : Confirmation
|
||||
}
|
||||
|
||||
sealed interface Progress {
|
||||
object Hidden : Progress
|
||||
object Shown : Progress
|
||||
data object Hidden : Progress
|
||||
data object Shown : Progress
|
||||
}
|
||||
|
||||
sealed interface Error {
|
||||
object Hidden : Error
|
||||
object Shown : Error
|
||||
data object Hidden : Error
|
||||
data object Shown : Error
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,6 @@ dependencies {
|
|||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.features.analytics.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.features.messages.test)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,5 +17,5 @@
|
|||
package io.element.android.features.location.impl.common.permissions
|
||||
|
||||
sealed interface PermissionsEvents {
|
||||
object RequestPermissions : PermissionsEvents
|
||||
data object RequestPermissions : PermissionsEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ data class PermissionsState(
|
|||
val eventSink: (PermissionsEvents) -> Unit = {},
|
||||
) {
|
||||
sealed interface Permissions {
|
||||
object AllGranted : Permissions
|
||||
object SomeGranted : Permissions
|
||||
object NoneGranted : Permissions
|
||||
data object AllGranted : Permissions
|
||||
data object SomeGranted : Permissions
|
||||
data object NoneGranted : Permissions
|
||||
}
|
||||
|
||||
val isAnyGranted: Boolean
|
||||
|
|
|
|||
|
|
@ -30,13 +30,9 @@ sealed interface SendLocationEvents {
|
|||
)
|
||||
}
|
||||
|
||||
object SwitchToMyLocationMode : SendLocationEvents
|
||||
|
||||
object SwitchToPinLocationMode : SendLocationEvents
|
||||
|
||||
object DismissDialog : SendLocationEvents
|
||||
|
||||
object RequestPermissions : SendLocationEvents
|
||||
|
||||
object OpenAppSettings : SendLocationEvents
|
||||
data object SwitchToMyLocationMode : SendLocationEvents
|
||||
data object SwitchToPinLocationMode : SendLocationEvents
|
||||
data object DismissDialog : SendLocationEvents
|
||||
data object RequestPermissions : SendLocationEvents
|
||||
data object OpenAppSettings : SendLocationEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,13 +24,13 @@ data class SendLocationState(
|
|||
val eventSink: (SendLocationEvents) -> Unit = {},
|
||||
) {
|
||||
sealed interface Mode {
|
||||
object SenderLocation : Mode
|
||||
object PinLocation : Mode
|
||||
data object SenderLocation : Mode
|
||||
data object PinLocation : Mode
|
||||
}
|
||||
|
||||
sealed interface Dialog {
|
||||
object None : Dialog
|
||||
object PermissionRationale : Dialog
|
||||
object PermissionDenied : Dialog
|
||||
data object None : Dialog
|
||||
data object PermissionRationale : Dialog
|
||||
data object PermissionDenied : Dialog
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,11 +17,9 @@
|
|||
package io.element.android.features.location.impl.show
|
||||
|
||||
sealed interface ShowLocationEvents {
|
||||
object Share : ShowLocationEvents
|
||||
data object Share : ShowLocationEvents
|
||||
data class TrackMyLocation(val enabled: Boolean) : ShowLocationEvents
|
||||
object DismissDialog : ShowLocationEvents
|
||||
|
||||
object RequestPermissions : ShowLocationEvents
|
||||
|
||||
object OpenAppSettings : ShowLocationEvents
|
||||
data object DismissDialog : ShowLocationEvents
|
||||
data object RequestPermissions : ShowLocationEvents
|
||||
data object OpenAppSettings : ShowLocationEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ data class ShowLocationState(
|
|||
val eventSink: (ShowLocationEvents) -> Unit,
|
||||
) {
|
||||
sealed interface Dialog {
|
||||
object None : Dialog
|
||||
object PermissionRationale : Dialog
|
||||
object PermissionDenied : Dialog
|
||||
data object None : Dialog
|
||||
data object PermissionRationale : Dialog
|
||||
data object PermissionDenied : Dialog
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import app.cash.molecule.moleculeFlow
|
|||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth
|
||||
import im.vector.app.features.analytics.plan.Composer
|
||||
import io.element.android.features.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
||||
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
||||
|
|
@ -34,6 +33,7 @@ import io.element.android.libraries.matrix.test.core.aBuildMeta
|
|||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.SendLocationInvocation
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
|
|
|||
|
|
@ -17,6 +17,6 @@
|
|||
package io.element.android.features.login.api.oidc
|
||||
|
||||
sealed interface OidcAction {
|
||||
object GoBack : OidcAction
|
||||
data object GoBack : OidcAction
|
||||
data class Success(val url: String) : OidcAction
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
|
|
@ -33,6 +35,8 @@ import com.bumble.appyx.navmodel.backstack.operation.singleTop
|
|||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.features.login.api.oidc.OidcActionFlow
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||
import io.element.android.features.login.impl.oidc.CustomTabAvailabilityChecker
|
||||
import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler
|
||||
|
|
@ -51,6 +55,8 @@ import io.element.android.libraries.architecture.inputs
|
|||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
|
|
@ -61,6 +67,7 @@ class LoginFlowNode @AssistedInject constructor(
|
|||
private val customTabHandler: CustomTabHandler,
|
||||
private val accountProviderDataSource: AccountProviderDataSource,
|
||||
private val defaultLoginUserStory: DefaultLoginUserStory,
|
||||
private val oidcActionFlow: OidcActionFlow,
|
||||
) : BackstackNode<LoginFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.ConfirmAccountProvider,
|
||||
|
|
@ -78,23 +85,40 @@ class LoginFlowNode @AssistedInject constructor(
|
|||
|
||||
private val inputs: Inputs = inputs()
|
||||
|
||||
private var customChromeTabStarted = false
|
||||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
defaultLoginUserStory.setLoginFlowIsDone(false)
|
||||
lifecycle.subscribe(
|
||||
onResume = {
|
||||
if (customChromeTabStarted) {
|
||||
customChromeTabStarted = false
|
||||
// Workaround to detect that the Custom Chrome Tab has been closed
|
||||
// If there is no coming OidcAction (that would end this Node),
|
||||
// consider that the user has cancelled the login
|
||||
// by pressing back or by closing the Custom Chrome Tab.
|
||||
lifecycleScope.launch {
|
||||
delay(5000)
|
||||
oidcActionFlow.post(OidcAction.GoBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object ConfirmAccountProvider : NavTarget
|
||||
data object ConfirmAccountProvider : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object ChangeAccountProvider : NavTarget
|
||||
data object ChangeAccountProvider : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object SearchAccountProvider : NavTarget
|
||||
data object SearchAccountProvider : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object LoginPassword : NavTarget
|
||||
data object LoginPassword : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class WaitList(val loginFormState: LoginFormState) : NavTarget
|
||||
|
|
@ -113,7 +137,10 @@ class LoginFlowNode @AssistedInject constructor(
|
|||
override fun onOidcDetails(oidcDetails: OidcDetails) {
|
||||
if (customTabAvailabilityChecker.supportCustomTab()) {
|
||||
// In this case open a Chrome Custom tab
|
||||
activity?.let { customTabHandler.open(it, darkTheme, oidcDetails.url) }
|
||||
activity?.let {
|
||||
customChromeTabStarted = true
|
||||
customTabHandler.open(it, darkTheme, oidcDetails.url)
|
||||
}
|
||||
} else {
|
||||
// Fallback to WebView mode
|
||||
backstack.push(NavTarget.OidcView(oidcDetails))
|
||||
|
|
|
|||
|
|
@ -20,5 +20,5 @@ import io.element.android.features.login.impl.accountprovider.AccountProvider
|
|||
|
||||
sealed interface ChangeServerEvents {
|
||||
data class ChangeServer(val accountProvider: AccountProvider) : ChangeServerEvents
|
||||
object ClearError : ChangeServerEvents
|
||||
data object ClearError : ChangeServerEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ sealed class ChangeServerError : Throwable() {
|
|||
@Composable
|
||||
fun message(): String = stringResource(messageId)
|
||||
}
|
||||
object SlidingSyncAlert : ChangeServerError()
|
||||
data object SlidingSyncAlert : ChangeServerError()
|
||||
|
||||
companion object {
|
||||
fun from(error: Throwable): ChangeServerError = when (error) {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ package io.element.android.features.login.impl.oidc.webview
|
|||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
|
||||
sealed interface OidcEvents {
|
||||
object Cancel : OidcEvents
|
||||
data object Cancel : OidcEvents
|
||||
data class OidcActionEvent(val oidcAction: OidcAction): OidcEvents
|
||||
object ClearError : OidcEvents
|
||||
data object ClearError : OidcEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,6 @@
|
|||
package io.element.android.features.login.impl.screens.confirmaccountprovider
|
||||
|
||||
sealed interface ConfirmAccountProviderEvents {
|
||||
object Continue : ConfirmAccountProviderEvents
|
||||
object ClearError : ConfirmAccountProviderEvents
|
||||
data object Continue : ConfirmAccountProviderEvents
|
||||
data object ClearError : ConfirmAccountProviderEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,9 +68,9 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
|
|||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
defaultOidcActionFlow.collect {
|
||||
onOidcAction(it, loginFlowAction)
|
||||
defaultOidcActionFlow.collect { oidcAction ->
|
||||
if (oidcAction != null) {
|
||||
onOidcAction(oidcAction, loginFlowAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -113,10 +113,9 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
|
|||
}
|
||||
|
||||
private suspend fun onOidcAction(
|
||||
oidcAction: OidcAction?,
|
||||
oidcAction: OidcAction,
|
||||
loginFlowAction: MutableState<Async<LoginFlow>>,
|
||||
) {
|
||||
oidcAction ?: return
|
||||
loginFlowAction.value = Async.Loading()
|
||||
when (oidcAction) {
|
||||
OidcAction.GoBack -> {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,6 @@ data class ConfirmAccountProviderState(
|
|||
}
|
||||
|
||||
sealed interface LoginFlow {
|
||||
object PasswordLogin : LoginFlow
|
||||
data object PasswordLogin : LoginFlow
|
||||
data class OidcFlow(val oidcDetails: OidcDetails) : LoginFlow
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,6 @@ package io.element.android.features.login.impl.screens.loginpassword
|
|||
sealed interface LoginPasswordEvents {
|
||||
data class SetLogin(val login: String) : LoginPasswordEvents
|
||||
data class SetPassword(val password: String) : LoginPasswordEvents
|
||||
object Submit : LoginPasswordEvents
|
||||
object ClearError : LoginPasswordEvents
|
||||
data object Submit : LoginPasswordEvents
|
||||
data object ClearError : LoginPasswordEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
package io.element.android.features.login.impl.screens.waitlistscreen
|
||||
|
||||
sealed interface WaitListEvents {
|
||||
object AttemptLogin : WaitListEvents
|
||||
object ClearError : WaitListEvents
|
||||
object Continue : WaitListEvents
|
||||
data object AttemptLogin : WaitListEvents
|
||||
data object ClearError : WaitListEvents
|
||||
data object Continue : WaitListEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,32 +16,17 @@
|
|||
|
||||
package io.element.android.features.login.impl.screens.waitlistscreen
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.BiasAbsoluteAlignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -50,15 +35,13 @@ import io.element.android.features.login.impl.R
|
|||
import io.element.android.features.login.impl.error.isWaitListError
|
||||
import io.element.android.features.login.impl.error.loginError
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.atomic.pages.SunsetPage
|
||||
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
// Ref: https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?type=design&node-id=6761-148425
|
||||
|
|
@ -75,12 +58,7 @@ fun WaitListView(
|
|||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
WaitListBackground()
|
||||
WaitListContent(state, onCancelClicked)
|
||||
WaitListError(state)
|
||||
}
|
||||
WaitListContent(state, onCancelClicked, modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -101,136 +79,69 @@ private fun WaitListError(state: WaitListState) {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WaitListBackground(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(0.3f)
|
||||
.background(Color.White)
|
||||
)
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
painter = painterResource(id = R.drawable.light_dark),
|
||||
contentScale = ContentScale.Crop,
|
||||
contentDescription = null,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(0.7f)
|
||||
.background(Color(0xFF121418))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WaitListContent(
|
||||
state: WaitListState,
|
||||
onCancelClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ElementTheme(
|
||||
darkTheme = true
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
) {
|
||||
if (state.loginAction !is Async.Success) {
|
||||
CompositionLocalProvider(LocalContentColor provides Color.Black) {
|
||||
TextButton(
|
||||
text = stringResource(CommonStrings.action_cancel),
|
||||
onClick = onCancelClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = BiasAbsoluteAlignment(
|
||||
horizontalBias = 0f,
|
||||
verticalBias = -0.05f
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (state.loginAction.isLoading()) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = ElementTheme.colors.iconPrimary
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
val titleRes = when (state.loginAction) {
|
||||
is Async.Success -> R.string.screen_waitlist_title_success
|
||||
else -> R.string.screen_waitlist_title
|
||||
}
|
||||
Text(
|
||||
text = withColoredPeriod(titleRes),
|
||||
style = ElementTheme.typography.fontHeadingXlBold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val subtitle = when (state.loginAction) {
|
||||
is Async.Success -> stringResource(
|
||||
id = R.string.screen_waitlist_message_success,
|
||||
state.appName,
|
||||
)
|
||||
else -> stringResource(
|
||||
id = R.string.screen_waitlist_message,
|
||||
state.appName,
|
||||
state.serverName,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.widthIn(max = 360.dp),
|
||||
text = subtitle,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
textAlign = TextAlign.Center,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.loginAction is Async.Success) {
|
||||
Button(
|
||||
text = stringResource(id = CommonStrings.action_continue),
|
||||
onClick = { state.eventSink.invoke(WaitListEvents.Continue) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 8.dp),
|
||||
)
|
||||
val title = stringResource(
|
||||
when (state.loginAction) {
|
||||
is Async.Success -> R.string.screen_waitlist_title_success
|
||||
else -> R.string.screen_waitlist_title
|
||||
}
|
||||
)
|
||||
val subtitle = when (state.loginAction) {
|
||||
is Async.Success -> stringResource(
|
||||
id = R.string.screen_waitlist_message_success,
|
||||
state.appName,
|
||||
)
|
||||
else -> stringResource(
|
||||
id = R.string.screen_waitlist_message,
|
||||
state.appName,
|
||||
state.serverName,
|
||||
)
|
||||
}
|
||||
SunsetPage(
|
||||
isLoading = state.loginAction.isLoading(),
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
) {
|
||||
OverallContent(state, onCancelClicked)
|
||||
}
|
||||
WaitListError(state)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun withColoredPeriod(
|
||||
@StringRes textRes: Int,
|
||||
) = buildAnnotatedString {
|
||||
val text = stringResource(textRes)
|
||||
append(text)
|
||||
if (text.endsWith(".")) {
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
// Light.colorGreen700
|
||||
color = Color(0xff0bc491),
|
||||
),
|
||||
start = text.length - 1,
|
||||
end = text.length,
|
||||
)
|
||||
private fun OverallContent(
|
||||
state: WaitListState,
|
||||
onCancelClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
if (state.loginAction !is Async.Success) {
|
||||
CompositionLocalProvider(LocalContentColor provides Color.Black) {
|
||||
TextButton(
|
||||
text = stringResource(CommonStrings.action_cancel),
|
||||
onClick = onCancelClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.loginAction is Async.Success) {
|
||||
Button(
|
||||
text = stringResource(id = CommonStrings.action_continue),
|
||||
onClick = { state.eventSink.invoke(WaitListEvents.Continue) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 104 KiB |
|
|
@ -9,7 +9,7 @@
|
|||
<string name="screen_account_provider_signin_title">"Chystáte se přihlásit do %s"</string>
|
||||
<string name="screen_account_provider_signup_subtitle">"Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily."</string>
|
||||
<string name="screen_account_provider_signup_title">"Chystáte se vytvořit účet na %s"</string>
|
||||
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org je otevřená síť pro bezpečnou, decentralizovanou komunikaci."</string>
|
||||
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org je velký bezplatný server ve veřejné síti Matrix pro bezpečnou decentralizovanou komunikaci, který provozuje nadace Matrix.org."</string>
|
||||
<string name="screen_change_account_provider_other">"Jiný"</string>
|
||||
<string name="screen_change_account_provider_subtitle">"Použijte jiného poskytovatele účtu, například vlastní soukromý server nebo pracovní účet."</string>
|
||||
<string name="screen_change_account_provider_title">"Změnit poskytovatele účtu"</string>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<string name="screen_account_provider_signin_title">"Du bist dabei dich bei %s anzumelden"</string>
|
||||
<string name="screen_account_provider_signup_subtitle">"Hier werden deine Konversationen stattfinden — genauso wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren."</string>
|
||||
<string name="screen_account_provider_signup_title">"Du bist dabei ein Konto auf %s zu erstellen"</string>
|
||||
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org ist ein offenes Netzwerk für sichere, dezentralisierte Kommunikation."</string>
|
||||
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org ist ein großer, kostenloser Server im öffentlichen Matrix-Netzwerk für sichere, dezentrale Kommunikation, der von der Matrix.org Foundation betrieben wird."</string>
|
||||
<string name="screen_change_account_provider_other">"Andere"</string>
|
||||
<string name="screen_change_account_provider_subtitle">"Verwende einen anderen Kontoanbieter, z. B. deinen eigenen privaten Server oder ein Arbeitskonto."</string>
|
||||
<string name="screen_change_account_provider_title">"Kontoanbieter ändern"</string>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
<string name="screen_account_provider_signin_title">"Vous êtes sur le point de vous connecter à %s"</string>
|
||||
<string name="screen_account_provider_signup_subtitle">"C\'est ici que vos conversations seront stockées - tout comme vous utiliseriez un fournisseur de messagerie pour conserver vos e-mails."</string>
|
||||
<string name="screen_account_provider_signup_title">"Vous êtes sur le point de créer un compte sur %s"</string>
|
||||
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org est un réseau ouvert pour des communications sécurisées et décentralisées."</string>
|
||||
<string name="screen_change_account_provider_other">"Autre"</string>
|
||||
<string name="screen_change_account_provider_subtitle">"Utilisez un autre fournisseur de compte, tel que votre propre serveur ou un compte professionnel."</string>
|
||||
<string name="screen_change_account_provider_title">"Changer de fournisseur"</string>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
<string name="screen_account_provider_signin_title">"Sunteți pe cale să vă conectați la %s"</string>
|
||||
<string name="screen_account_provider_signup_subtitle">"Aici vor trăi conversațiile - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."</string>
|
||||
<string name="screen_account_provider_signup_title">"Sunteți pe cale să creați un cont pe %s"</string>
|
||||
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org este o rețea deschisă pentru o comunicare sigură și descentralizată."</string>
|
||||
<string name="screen_change_account_provider_other">"Altul"</string>
|
||||
<string name="screen_change_account_provider_subtitle">"Utilizați un alt furnizor de cont, cum ar fi propriul server privat sau un cont de serviciu."</string>
|
||||
<string name="screen_change_account_provider_title">"Schimbați furnizorul contului"</string>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
<string name="screen_account_provider_signin_title">"Вы собираетесь войти в %s"</string>
|
||||
<string name="screen_account_provider_signup_subtitle">"Здесь будут храниться ваши разговоры - точно так же, как вы используете почтового провайдера для хранения своих писем."</string>
|
||||
<string name="screen_account_provider_signup_title">"Вы собираетесь создать учетную запись на %s"</string>
|
||||
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org — это открытая сеть для безопасной децентрализованной связи."</string>
|
||||
<string name="screen_change_account_provider_other">"Другое"</string>
|
||||
<string name="screen_change_account_provider_subtitle">"Используйте другого поставщика учетных записей, например, собственный частный сервер или рабочую учетную запись."</string>
|
||||
<string name="screen_change_account_provider_title">"Сменить поставщика учетной записи"</string>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
<string name="screen_account_provider_signin_title">"Chystáte sa prihlásiť do %s"</string>
|
||||
<string name="screen_account_provider_signup_subtitle">"Tu budú žiť vaše konverzácie — podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov."</string>
|
||||
<string name="screen_account_provider_signup_title">"Chystáte sa vytvoriť účet na %s"</string>
|
||||
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu."</string>
|
||||
<string name="screen_change_account_provider_other">"Iný"</string>
|
||||
<string name="screen_change_account_provider_subtitle">"Použite iného poskytovateľa účtu, ako napríklad vlastný súkromný server alebo pracovný účet."</string>
|
||||
<string name="screen_change_account_provider_title">"Zmeniť poskytovateľa účtu"</string>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<string name="screen_account_provider_signin_title">"You’re about to sign in to %s"</string>
|
||||
<string name="screen_account_provider_signup_subtitle">"This is where your conversations will live — just like you would use an email provider to keep your emails."</string>
|
||||
<string name="screen_account_provider_signup_title">"You’re about to create an account on %s"</string>
|
||||
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org is an open network for secure, decentralized communication."</string>
|
||||
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org is a large, free server on the public Matrix network for secure, decentralised communication, run by the Matrix.org Foundation."</string>
|
||||
<string name="screen_change_account_provider_other">"Other"</string>
|
||||
<string name="screen_change_account_provider_subtitle">"Use a different account provider, such as your own private server or a work account."</string>
|
||||
<string name="screen_change_account_provider_title">"Change account provider"</string>
|
||||
|
|
|
|||
|
|
@ -17,5 +17,5 @@
|
|||
package io.element.android.features.logout.api
|
||||
|
||||
sealed interface LogoutPreferenceEvents {
|
||||
object Logout : LogoutPreferenceEvents
|
||||
data object Logout : LogoutPreferenceEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
|||
@Composable
|
||||
fun LogoutPreferenceView(
|
||||
state: LogoutPreferenceState,
|
||||
onSuccessLogout: (String?) -> Unit = {}
|
||||
onSuccessLogout: (logoutUrlResult: String?) -> Unit
|
||||
) {
|
||||
val eventSink = state.eventSink
|
||||
if (state.logoutAction is Async.Success) {
|
||||
|
|
@ -96,5 +96,8 @@ internal fun LogoutPreferenceViewDarkPreview() = ElementPreviewDark { ContentToP
|
|||
|
||||
@Composable
|
||||
private fun ContentToPreview() {
|
||||
LogoutPreferenceView(aLogoutPreferenceState())
|
||||
LogoutPreferenceView(
|
||||
aLogoutPreferenceState(),
|
||||
onSuccessLogout = {}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ dependencies {
|
|||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.dateformatter.test)
|
||||
testImplementation(projects.features.networkmonitor.test)
|
||||
testImplementation(projects.features.analytics.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
testImplementation(projects.libraries.mediaupload.test)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ sealed interface MessagesEvents {
|
|||
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents
|
||||
data class ToggleReaction(val emoji: String, val eventId: EventId) : MessagesEvents
|
||||
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvents
|
||||
object Dismiss : MessagesEvents
|
||||
data object Dismiss : MessagesEvents
|
||||
}
|
||||
|
||||
enum class InviteDialogAction {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -75,7 +77,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object Messages : NavTarget
|
||||
data object Messages : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class MediaViewer(
|
||||
|
|
@ -100,7 +102,10 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object SendLocation : NavTarget
|
||||
data object SendLocation : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object CreatePoll : NavTarget
|
||||
}
|
||||
|
||||
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
|
||||
|
|
@ -140,6 +145,10 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
override fun onSendLocationClicked() {
|
||||
backstack.push(NavTarget.SendLocation)
|
||||
}
|
||||
|
||||
override fun onCreatePollClicked() {
|
||||
backstack.push(NavTarget.CreatePoll)
|
||||
}
|
||||
}
|
||||
createNode<MessagesNode>(buildContext, listOf(callback))
|
||||
}
|
||||
|
|
@ -179,6 +188,9 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
NavTarget.SendLocation -> {
|
||||
sendLocationEntryPoint.createNode(this, buildContext)
|
||||
}
|
||||
NavTarget.CreatePoll -> {
|
||||
createPollEntryPoint.createNode(this, buildContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ class MessagesNode @AssistedInject constructor(
|
|||
fun onForwardEventClicked(eventId: EventId)
|
||||
fun onReportMessage(eventId: EventId, senderId: UserId)
|
||||
fun onSendLocationClicked()
|
||||
fun onCreatePollClicked()
|
||||
}
|
||||
|
||||
init {
|
||||
|
|
@ -99,6 +100,10 @@ class MessagesNode @AssistedInject constructor(
|
|||
callback?.onSendLocationClicked()
|
||||
}
|
||||
|
||||
private fun onCreatePollClicked() {
|
||||
callback?.onCreatePollClicked()
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
|
|
@ -110,6 +115,7 @@ class MessagesNode @AssistedInject constructor(
|
|||
onPreviewAttachments = this::onPreviewAttachments,
|
||||
onUserDataClicked = this::onUserDataClicked,
|
||||
onSendLocationClicked = this::onSendLocationClicked,
|
||||
onCreatePollClicked = this::onCreatePollClicked,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -175,6 +176,7 @@ fun MessagesView(
|
|||
onReactionLongClicked = ::onEmojiReactionLongClicked,
|
||||
onMoreReactionsClicked = ::onMoreReactionsClicked,
|
||||
onSendLocationClicked = onSendLocationClicked,
|
||||
onCreatePollClicked = onCreatePollClicked,
|
||||
onSwipeToReply = { targetEvent ->
|
||||
state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, targetEvent))
|
||||
},
|
||||
|
|
@ -267,6 +269,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 +298,7 @@ private fun MessagesViewContent(
|
|||
MessageComposerView(
|
||||
state = state.composerState,
|
||||
onSendLocationClicked = onSendLocationClicked,
|
||||
onCreatePollClicked = onCreatePollClicked,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(Alignment.Bottom)
|
||||
|
|
@ -401,5 +405,6 @@ private fun ContentToPreview(state: MessagesState) {
|
|||
onPreviewAttachments = {},
|
||||
onUserDataClicked = {},
|
||||
onSendLocationClicked = {},
|
||||
onCreatePollClicked = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,6 @@ package io.element.android.features.messages.impl.actionlist
|
|||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
|
||||
sealed interface ActionListEvents {
|
||||
object Clear : ActionListEvents
|
||||
data object Clear : ActionListEvents
|
||||
data class ComputeForMessage(val event: TimelineItem.Event, val canRedact: Boolean) : ActionListEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,22 @@ class ActionListPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
is TimelineItemPollContent -> {
|
||||
buildList {
|
||||
if (timelineItem.content.canBeCopied()) {
|
||||
add(TimelineItemAction.Copy)
|
||||
}
|
||||
if (buildMeta.isDebuggable) {
|
||||
add(TimelineItemAction.Developer)
|
||||
}
|
||||
if (!timelineItem.isMine) {
|
||||
add(TimelineItemAction.ReportContent)
|
||||
}
|
||||
if (timelineItem.isMine || userCanRedact) {
|
||||
add(TimelineItemAction.Redact)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> buildList<TimelineItemAction> {
|
||||
if (timelineItem.isRemote) {
|
||||
// Can only reply or forward messages already uploaded to the server
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ data class ActionListState(
|
|||
val eventSink: (ActionListEvents) -> Unit,
|
||||
) {
|
||||
sealed interface Target {
|
||||
object None : Target
|
||||
data object None : Target
|
||||
data class Loading(val event: TimelineItem.Event) : Target
|
||||
data class Success(
|
||||
val event: TimelineItem.Event,
|
||||
|
|
|
|||
|
|
@ -28,11 +28,11 @@ sealed class TimelineItemAction(
|
|||
@DrawableRes val icon: Int,
|
||||
val destructive: Boolean = false
|
||||
) {
|
||||
object Forward : TimelineItemAction(CommonStrings.action_forward, VectorIcons.Forward)
|
||||
object Copy : TimelineItemAction(CommonStrings.action_copy, VectorIcons.Copy)
|
||||
object Redact : TimelineItemAction(CommonStrings.action_remove, VectorIcons.Delete, destructive = true)
|
||||
object Reply : TimelineItemAction(CommonStrings.action_reply, VectorIcons.Reply)
|
||||
object Edit : TimelineItemAction(CommonStrings.action_edit, VectorIcons.Edit)
|
||||
object Developer : TimelineItemAction(CommonStrings.action_view_source, VectorIcons.DeveloperMode)
|
||||
object ReportContent : TimelineItemAction(CommonStrings.action_report_content, VectorIcons.ReportContent, destructive = true)
|
||||
data object Forward : TimelineItemAction(CommonStrings.action_forward, VectorIcons.Forward)
|
||||
data object Copy : TimelineItemAction(CommonStrings.action_copy, VectorIcons.Copy)
|
||||
data object Redact : TimelineItemAction(CommonStrings.action_remove, VectorIcons.Delete, destructive = true)
|
||||
data object Reply : TimelineItemAction(CommonStrings.action_reply, VectorIcons.Reply)
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,6 @@ import androidx.compose.runtime.Immutable
|
|||
|
||||
@Immutable
|
||||
sealed interface AttachmentsPreviewEvents {
|
||||
object SendAttachment : AttachmentsPreviewEvents
|
||||
object ClearSendState : AttachmentsPreviewEvents
|
||||
data object SendAttachment : AttachmentsPreviewEvents
|
||||
data object ClearSendState : AttachmentsPreviewEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,13 +25,13 @@ data class AttachmentsPreviewState(
|
|||
)
|
||||
|
||||
sealed interface SendActionState {
|
||||
object Idle : SendActionState
|
||||
data object Idle : SendActionState
|
||||
sealed interface Sending : SendActionState {
|
||||
object Processing : Sending
|
||||
data object Processing : Sending
|
||||
data class Uploading(val progress: Float) : Sending
|
||||
}
|
||||
|
||||
data class Failure(val error: Throwable) : SendActionState
|
||||
object Done : SendActionState
|
||||
data object Done : SendActionState
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
|
|||
sealed interface ForwardMessagesEvents {
|
||||
data class SetSelectedRoom(val room: RoomSummaryDetails) : ForwardMessagesEvents
|
||||
// TODO remove to restore multi-selection
|
||||
object RemoveSelectedRoom : ForwardMessagesEvents
|
||||
object ToggleSearchActive : ForwardMessagesEvents
|
||||
data object RemoveSelectedRoom : ForwardMessagesEvents
|
||||
data object ToggleSearchActive : ForwardMessagesEvents
|
||||
data class UpdateQuery(val query: String) : ForwardMessagesEvents
|
||||
object ForwardEvent : ForwardMessagesEvents
|
||||
object ClearError : ForwardMessagesEvents
|
||||
data object ForwardEvent : ForwardMessagesEvents
|
||||
data object ClearError : ForwardMessagesEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@
|
|||
package io.element.android.features.messages.impl.media.viewer
|
||||
|
||||
sealed interface MediaViewerEvents {
|
||||
object SaveOnDisk: MediaViewerEvents
|
||||
object Share: MediaViewerEvents
|
||||
object OpenWith: MediaViewerEvents
|
||||
object RetryLoading : MediaViewerEvents
|
||||
object ClearLoadingError : MediaViewerEvents
|
||||
data object SaveOnDisk: MediaViewerEvents
|
||||
data object Share: MediaViewerEvents
|
||||
data object OpenWith: MediaViewerEvents
|
||||
data object RetryLoading : MediaViewerEvents
|
||||
data object ClearLoadingError : MediaViewerEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,20 +21,21 @@ import io.element.android.libraries.textcomposer.MessageComposerMode
|
|||
|
||||
@Immutable
|
||||
sealed interface MessageComposerEvents {
|
||||
object ToggleFullScreenState : MessageComposerEvents
|
||||
data object ToggleFullScreenState : MessageComposerEvents
|
||||
data class FocusChanged(val hasFocus: Boolean) : MessageComposerEvents
|
||||
data class SendMessage(val message: String) : MessageComposerEvents
|
||||
object CloseSpecialMode : MessageComposerEvents
|
||||
data object CloseSpecialMode : MessageComposerEvents
|
||||
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
|
||||
data class UpdateText(val text: String) : MessageComposerEvents
|
||||
object AddAttachment : MessageComposerEvents
|
||||
object DismissAttachmentMenu : MessageComposerEvents
|
||||
data object AddAttachment : MessageComposerEvents
|
||||
data object DismissAttachmentMenu : MessageComposerEvents
|
||||
sealed interface PickAttachmentSource : MessageComposerEvents {
|
||||
object FromGallery : PickAttachmentSource
|
||||
object FromFiles : PickAttachmentSource
|
||||
object PhotoFromCamera : PickAttachmentSource
|
||||
object VideoFromCamera : PickAttachmentSource
|
||||
object Location : PickAttachmentSource
|
||||
data object FromGallery : PickAttachmentSource
|
||||
data object FromFiles : PickAttachmentSource
|
||||
data object PhotoFromCamera : PickAttachmentSource
|
||||
data object VideoFromCamera : PickAttachmentSource
|
||||
data object Location : PickAttachmentSource
|
||||
data object Poll : PickAttachmentSource
|
||||
}
|
||||
object CancelSendAttachment : MessageComposerEvents
|
||||
data object CancelSendAttachment : MessageComposerEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,11 @@ class MessageComposerPresenter @Inject constructor(
|
|||
canShareLocation.value = featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)
|
||||
}
|
||||
|
||||
val canCreatePoll = remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
canCreatePoll.value = featureFlagService.isFeatureEnabled(FeatureFlags.Polls)
|
||||
}
|
||||
|
||||
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
|
||||
handlePickedMedia(attachmentsState, uri, mimeType)
|
||||
}
|
||||
|
|
@ -179,6 +184,10 @@ class MessageComposerPresenter @Inject constructor(
|
|||
showAttachmentSourcePicker = false
|
||||
// Navigation to the location picker screen is done at the view layer
|
||||
}
|
||||
MessageComposerEvents.PickAttachmentSource.Poll -> {
|
||||
showAttachmentSourcePicker = false
|
||||
// Navigation to the create poll screen is done at the view layer
|
||||
}
|
||||
is MessageComposerEvents.CancelSendAttachment -> {
|
||||
ongoingSendAttachmentJob.value?.let {
|
||||
it.cancel()
|
||||
|
|
@ -195,6 +204,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
mode = messageComposerContext.composerMode,
|
||||
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||
canShareLocation = canShareLocation.value,
|
||||
canCreatePoll = canCreatePoll.value,
|
||||
attachmentsState = attachmentsState.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ data class MessageComposerState(
|
|||
val mode: MessageComposerMode,
|
||||
val showAttachmentSourcePicker: Boolean,
|
||||
val canShareLocation: Boolean,
|
||||
val canCreatePoll: Boolean,
|
||||
val attachmentsState: AttachmentsState,
|
||||
val eventSink: (MessageComposerEvents) -> Unit
|
||||
) {
|
||||
|
|
@ -37,7 +38,7 @@ data class MessageComposerState(
|
|||
|
||||
@Immutable
|
||||
sealed interface AttachmentsState {
|
||||
object None : AttachmentsState
|
||||
data object None : AttachmentsState
|
||||
data class Previewing(val attachments: ImmutableList<Attachment>) : AttachmentsState
|
||||
sealed interface Sending : AttachmentsState {
|
||||
data class Processing(val attachments: ImmutableList<Attachment>) : Sending
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ fun aMessageComposerState(
|
|||
mode: MessageComposerMode = MessageComposerMode.Normal(content = ""),
|
||||
showAttachmentSourcePicker: Boolean = false,
|
||||
canShareLocation: Boolean = true,
|
||||
canCreatePoll: Boolean = true,
|
||||
attachmentsState: AttachmentsState = AttachmentsState.None,
|
||||
) = MessageComposerState(
|
||||
text = text,
|
||||
|
|
@ -41,6 +42,7 @@ fun aMessageComposerState(
|
|||
mode = mode,
|
||||
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||
canShareLocation = canShareLocation,
|
||||
canCreatePoll = canCreatePoll,
|
||||
attachmentsState = attachmentsState,
|
||||
eventSink = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import io.element.android.libraries.textcomposer.TextComposer
|
|||
fun MessageComposerView(
|
||||
state: MessageComposerState,
|
||||
onSendLocationClicked: () -> Unit,
|
||||
onCreatePollClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun onFullscreenToggle() {
|
||||
|
|
@ -59,6 +60,7 @@ fun MessageComposerView(
|
|||
AttachmentsBottomSheet(
|
||||
state = state,
|
||||
onSendLocationClicked = onSendLocationClicked,
|
||||
onCreatePollClicked = onCreatePollClicked,
|
||||
)
|
||||
|
||||
TextComposer(
|
||||
|
|
@ -88,6 +90,7 @@ internal fun MessageComposerViewDarkPreview(@PreviewParameter(MessageComposerSta
|
|||
private fun ContentToPreview(state: MessageComposerState) {
|
||||
MessageComposerView(
|
||||
state = state,
|
||||
onSendLocationClicked = {}
|
||||
onSendLocationClicked = {},
|
||||
onCreatePollClicked = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ package io.element.android.features.messages.impl.report
|
|||
|
||||
sealed interface ReportMessageEvents {
|
||||
data class UpdateReason(val reason: String) : ReportMessageEvents
|
||||
object ToggleBlockUser : ReportMessageEvents
|
||||
object Report : ReportMessageEvents
|
||||
object ClearError : ReportMessageEvents
|
||||
data object ToggleBlockUser : ReportMessageEvents
|
||||
data object Report : ReportMessageEvents
|
||||
data object ClearError : ReportMessageEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ package io.element.android.features.messages.impl.timeline
|
|||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
sealed interface TimelineEvents {
|
||||
object LoadMore : TimelineEvents
|
||||
data object LoadMore : TimelineEvents
|
||||
data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents
|
||||
data class OnScrollFinished(val firstIndex: Int) : TimelineEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
|
|
@ -68,9 +69,11 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemGrou
|
|||
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
|
||||
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.TimelineItemPollContent
|
||||
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.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.aTimelineItemTextContent
|
||||
import io.element.android.libraries.designsystem.components.EqualWidthColumn
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
|
|
@ -357,11 +360,15 @@ private fun MessageEventBubbleContent(
|
|||
onMessageLongClick: () -> Unit,
|
||||
inReplyToClick: () -> Unit,
|
||||
onTimestampClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
@SuppressLint("ModifierParameter") bubbleModifier: Modifier = Modifier, // need to rename this modifier to distinguish it from the following ones
|
||||
) {
|
||||
val isMediaItem = event.content is TimelineItemImageContent
|
||||
|| event.content is TimelineItemVideoContent
|
||||
|| event.content is TimelineItemLocationContent
|
||||
val timestampPosition = when (event.content) {
|
||||
is TimelineItemImageContent,
|
||||
is TimelineItemVideoContent,
|
||||
is TimelineItemLocationContent -> TimestampPosition.Overlay
|
||||
is TimelineItemPollContent -> TimestampPosition.Below
|
||||
else -> TimestampPosition.Default
|
||||
}
|
||||
val replyToDetails = event.inReplyTo as? InReplyTo.Ready
|
||||
|
||||
// Long clicks are not not automatically propagated from a `clickable`
|
||||
|
|
@ -384,96 +391,97 @@ private fun MessageEventBubbleContent(
|
|||
|
||||
@Composable
|
||||
fun ContentAndTimestampView(
|
||||
overlayTimestamp: Boolean,
|
||||
timestampPosition: TimestampPosition,
|
||||
modifier: Modifier = Modifier,
|
||||
contentModifier: Modifier = Modifier,
|
||||
timestampModifier: Modifier = Modifier,
|
||||
) {
|
||||
if (overlayTimestamp) {
|
||||
Box(modifier) {
|
||||
ContentView(modifier = contentModifier)
|
||||
TimelineEventTimestampView(
|
||||
event = event,
|
||||
onClick = onTimestampClicked,
|
||||
onLongClick = ::onTimestampLongClick,
|
||||
modifier = timestampModifier
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp) // Outer padding
|
||||
.background(ElementTheme.colors.bgSubtleSecondary, RoundedCornerShape(10.0.dp))
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp) // Inner padding
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Box(modifier) {
|
||||
ContentView(modifier = contentModifier)
|
||||
TimelineEventTimestampView(
|
||||
event = event,
|
||||
onClick = onTimestampClicked,
|
||||
onLongClick = ::onTimestampLongClick,
|
||||
modifier = timestampModifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
when (timestampPosition) {
|
||||
TimestampPosition.Overlay ->
|
||||
Box(modifier) {
|
||||
ContentView(modifier = contentModifier)
|
||||
TimelineEventTimestampView(
|
||||
event = event,
|
||||
onClick = onTimestampClicked,
|
||||
onLongClick = ::onTimestampLongClick,
|
||||
modifier = timestampModifier
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp) // Outer padding
|
||||
.background(ElementTheme.colors.bgSubtleSecondary, RoundedCornerShape(10.0.dp))
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp) // Inner padding
|
||||
)
|
||||
}
|
||||
TimestampPosition.Aligned ->
|
||||
Box(modifier) {
|
||||
ContentView(modifier = contentModifier)
|
||||
TimelineEventTimestampView(
|
||||
event = event,
|
||||
onClick = onTimestampClicked,
|
||||
onLongClick = ::onTimestampLongClick,
|
||||
modifier = timestampModifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
TimestampPosition.Below ->
|
||||
Column(modifier) {
|
||||
ContentView(modifier = contentModifier)
|
||||
TimelineEventTimestampView(
|
||||
event = event,
|
||||
onClick = onTimestampClicked,
|
||||
onLongClick = ::onTimestampLongClick,
|
||||
modifier = timestampModifier
|
||||
.align(Alignment.End)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Used only for media items, with no reply to metadata. It displays the contents with no paddings. */
|
||||
@Composable
|
||||
fun SimpleMediaItemLayout(modifier: Modifier = Modifier) {
|
||||
ContentAndTimestampView(overlayTimestamp = true, modifier = modifier)
|
||||
}
|
||||
|
||||
/** Used for every other type of message, groups the different components in a Column with some space between them. */
|
||||
/** Groups the different components in a Column with some space between them. */
|
||||
@Composable
|
||||
fun CommonLayout(
|
||||
inReplyToDetails: InReplyTo.Ready?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var modifierWithPadding: Modifier = Modifier
|
||||
var contentModifier: Modifier = Modifier
|
||||
EqualWidthColumn(modifier = modifier, spacing = 8.dp) {
|
||||
if (inReplyToDetails != null) {
|
||||
val senderName = inReplyToDetails.senderDisplayName ?: inReplyToDetails.senderId.value
|
||||
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToDetails)
|
||||
val text = textForInReplyTo(inReplyToDetails)
|
||||
ReplyToContent(
|
||||
senderName = senderName,
|
||||
text = text,
|
||||
attachmentThumbnailInfo = attachmentThumbnailInfo,
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp, start = 8.dp, end = 8.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable(enabled = true, onClick = inReplyToClick),
|
||||
)
|
||||
}
|
||||
val modifierWithPadding = if (isMediaItem) {
|
||||
Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
val contentModifier = if (isMediaItem) {
|
||||
Modifier.clip(RoundedCornerShape(12.dp))
|
||||
} else {
|
||||
if (inReplyToDetails != null) {
|
||||
Modifier.padding(start = 12.dp, end = 12.dp, top = 0.dp, bottom = 8.dp)
|
||||
} else {
|
||||
Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
|
||||
when {
|
||||
inReplyToDetails != null -> {
|
||||
val senderName = inReplyToDetails.senderDisplayName ?: inReplyToDetails.senderId.value
|
||||
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToDetails)
|
||||
val text = textForInReplyTo(inReplyToDetails)
|
||||
ReplyToContent(
|
||||
senderName = senderName,
|
||||
text = text,
|
||||
attachmentThumbnailInfo = attachmentThumbnailInfo,
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp, start = 8.dp, end = 8.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable(enabled = true, onClick = inReplyToClick),
|
||||
)
|
||||
if (timestampPosition == TimestampPosition.Overlay) {
|
||||
modifierWithPadding = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
|
||||
contentModifier = Modifier.clip(RoundedCornerShape(12.dp))
|
||||
} else {
|
||||
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 0.dp, bottom = 8.dp)
|
||||
}
|
||||
}
|
||||
timestampPosition != TimestampPosition.Overlay -> {
|
||||
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
|
||||
}
|
||||
}
|
||||
|
||||
ContentAndTimestampView(
|
||||
overlayTimestamp = isMediaItem,
|
||||
timestampPosition = timestampPosition,
|
||||
contentModifier = contentModifier,
|
||||
modifier = modifierWithPadding,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isMediaItem && replyToDetails == null) {
|
||||
SimpleMediaItemLayout()
|
||||
} else {
|
||||
CommonLayout(inReplyToDetails = replyToDetails, modifier = modifier)
|
||||
}
|
||||
CommonLayout(inReplyToDetails = replyToDetails, modifier = bubbleModifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -810,3 +818,23 @@ internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight {
|
|||
onTimestampClicked = {},
|
||||
)
|
||||
}
|
||||
|
||||
// Note: no need for light/dark variant for this preview, we only look at the timestamp position
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun TimelineItemEventTimestampBelowPreview() = ElementPreviewLight {
|
||||
TimelineItemEventRow(
|
||||
event = aTimelineItemEvent(content = aTimelineItemPollContent()),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onUserDataClick = {},
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onSwipeToReply = {},
|
||||
onTimestampClicked = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* 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
|
||||
|
||||
enum class TimestampPosition {
|
||||
/**
|
||||
* Timestamp should overlay the timeline event content (eg. image).
|
||||
*/
|
||||
Overlay,
|
||||
|
||||
/**
|
||||
* Timestamp should be aligned with the timeline event content if this is possible (eg. text).
|
||||
*/
|
||||
Aligned,
|
||||
|
||||
/**
|
||||
* Timestamp should always be rendered below the timeline event content (eg. poll).
|
||||
*/
|
||||
Below;
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Default timestamp position for timeline event contents.
|
||||
*/
|
||||
val Default: TimestampPosition = Aligned
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContentProvider
|
||||
import io.element.android.features.poll.api.ActivePollContentView
|
||||
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
|
||||
|
|
@ -33,10 +33,11 @@ fun TimelineItemPollView(
|
|||
onAnswerSelected: (PollAnswer) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ActivePollContentView(
|
||||
PollContentView(
|
||||
question = content.question,
|
||||
answerItems = content.answerItems.toImmutableList(),
|
||||
pollKind = content.pollKind,
|
||||
isPollEnded = content.isEnded,
|
||||
onAnswerSelected = onAnswerSelected,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,6 @@ import io.element.android.features.messages.impl.timeline.model.AggregatedReacti
|
|||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
sealed interface ReactionSummaryEvents {
|
||||
object Clear : ReactionSummaryEvents
|
||||
data object Clear : ReactionSummaryEvents
|
||||
data class ShowReactionSummary(val eventId: EventId, val reactions: List<AggregatedReaction>, val selectedKey: String) : ReactionSummaryEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
|||
|
||||
sealed interface RetrySendMenuEvents {
|
||||
data class EventSelected(val event: TimelineItem.Event) : RetrySendMenuEvents
|
||||
object RetrySend : RetrySendMenuEvents
|
||||
object RemoveFailed : RetrySendMenuEvents
|
||||
object Dismiss: RetrySendMenuEvents
|
||||
data object RetrySend : RetrySendMenuEvents
|
||||
data object RemoveFailed : RetrySendMenuEvents
|
||||
data object Dismiss: RetrySendMenuEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +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.poll.PollKind
|
||||
import io.element.android.libraries.matrix.api.poll.isDisclosed
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -36,27 +36,41 @@ class TimelineItemContentPollFactory @Inject constructor(
|
|||
if (!featureFlagService.isFeatureEnabled(FeatureFlags.Polls)) return TimelineItemUnknownContent
|
||||
|
||||
// Todo Move this computation to the matrix rust sdk
|
||||
val showResults = content.kind == PollKind.Disclosed && matrixClient.sessionId in content.votes.flatMap { it.value }
|
||||
val pollVotesCount = content.votes.flatMap { it.value }.size
|
||||
val userVotes = content.votes.filter { matrixClient.sessionId in it.value }.keys
|
||||
val totalVoteCount = content.votes.flatMap { it.value }.size
|
||||
val myVotes = content.votes.filter { matrixClient.sessionId in it.value }.keys
|
||||
val isEndedPoll = content.endTime != null
|
||||
val winnerIds = if (!isEndedPoll) {
|
||||
emptyList()
|
||||
} else {
|
||||
content.answers
|
||||
.map { answer -> answer.id }
|
||||
.groupBy { answerId -> content.votes[answerId]?.size ?: 0 } // Group by votes count
|
||||
.maxByOrNull { (votes, _) -> votes } // Keep max voted answers
|
||||
?.takeIf { (votes, _) -> votes > 0 } // Ignore if no option has been voted
|
||||
?.value
|
||||
.orEmpty()
|
||||
}
|
||||
val answerItems = content.answers.map { answer ->
|
||||
val votesCount = content.votes[answer.id]?.size ?: 0
|
||||
val progress = if (pollVotesCount > 0) votesCount.toFloat() / pollVotesCount.toFloat() else 0f
|
||||
val answerVoteCount = content.votes[answer.id]?.size ?: 0
|
||||
val isSelected = answer.id in myVotes
|
||||
val isWinner = answer.id in winnerIds
|
||||
val percentage = if (totalVoteCount > 0) answerVoteCount.toFloat() / totalVoteCount.toFloat() else 0f
|
||||
PollAnswerItem(
|
||||
answer = answer,
|
||||
isSelected = answer.id in userVotes,
|
||||
isDisclosed = showResults,
|
||||
votesCount = votesCount,
|
||||
progress = progress,
|
||||
isSelected = isSelected,
|
||||
isEnabled = !isEndedPoll,
|
||||
isWinner = isWinner,
|
||||
isDisclosed = content.kind.isDisclosed || isEndedPoll,
|
||||
votesCount = answerVoteCount,
|
||||
percentage = percentage,
|
||||
)
|
||||
}
|
||||
|
||||
return TimelineItemPollContent(
|
||||
question = content.question,
|
||||
answerItems = answerItems,
|
||||
votes = content.votes,
|
||||
pollKind = content.kind,
|
||||
isDisclosed = showResults
|
||||
isEnded = isEndedPoll,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue