Merge branch 'develop' into feature/fga/better_media_handling
This commit is contained in:
commit
84500d41eb
221 changed files with 4574 additions and 845 deletions
|
|
@ -16,12 +16,11 @@
|
|||
|
||||
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.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
|
|
@ -48,6 +47,8 @@ import androidx.compose.ui.unit.dp
|
|||
import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
||||
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
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.InfoListOrganism
|
||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
|
|
@ -60,6 +61,7 @@ import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
|
|||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Composable
|
||||
fun AnalyticsOptInView(
|
||||
|
|
@ -69,6 +71,16 @@ fun AnalyticsOptInView(
|
|||
) {
|
||||
LogCompositions(tag = "Analytics", msg = "Root")
|
||||
val eventSink = state.eventSink
|
||||
|
||||
fun onTermsAccepted() {
|
||||
eventSink(AnalyticsOptInEvents.EnableAnalytics(true))
|
||||
}
|
||||
|
||||
fun onTermsDeclined() {
|
||||
eventSink(AnalyticsOptInEvents.EnableAnalytics(false))
|
||||
}
|
||||
|
||||
BackHandler(onBack = ::onTermsDeclined)
|
||||
HeaderFooterPage(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -76,7 +88,13 @@ fun AnalyticsOptInView(
|
|||
.imePadding(),
|
||||
header = { AnalyticsOptInHeader(state, onClickTerms) },
|
||||
content = { AnalyticsOptInContent() },
|
||||
footer = { AnalyticsOptInFooter(eventSink) })
|
||||
footer = {
|
||||
AnalyticsOptInFooter(
|
||||
onTermsAccepted = ::onTermsAccepted,
|
||||
onTermsDeclined = ::onTermsDeclined,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -114,6 +132,19 @@ private fun AnalyticsOptInHeader(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckIcon(modifier: Modifier = Modifier) {
|
||||
Icon(
|
||||
modifier = modifier
|
||||
.size(20.dp)
|
||||
.background(color = MaterialTheme.colorScheme.background, shape = CircleShape)
|
||||
.padding(2.dp),
|
||||
imageVector = Icons.Rounded.Check,
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.textActionAccent,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AnalyticsOptInContent(
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -125,80 +156,45 @@ private fun AnalyticsOptInContent(
|
|||
verticalBias = -0.4f
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
AnalyticsOptInContentRow(
|
||||
text = stringResource(id = R.string.screen_analytics_prompt_data_usage),
|
||||
idx = 0
|
||||
)
|
||||
AnalyticsOptInContentRow(
|
||||
text = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing),
|
||||
idx = 1
|
||||
)
|
||||
AnalyticsOptInContentRow(
|
||||
text = stringResource(id = R.string.screen_analytics_prompt_settings),
|
||||
idx = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AnalyticsOptInContentRow(
|
||||
text: String,
|
||||
idx: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val radius = 14.dp
|
||||
val bgShape = when (idx) {
|
||||
0 -> RoundedCornerShape(topStart = radius, topEnd = radius)
|
||||
2 -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
|
||||
else -> RoundedCornerShape(0.dp)
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = ElementTheme.colors.temporaryColorBgSpecial,
|
||||
shape = bgShape,
|
||||
)
|
||||
.padding(vertical = 12.dp, horizontal = 20.dp),
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.background(color = MaterialTheme.colorScheme.background, shape = CircleShape)
|
||||
.padding(2.dp),
|
||||
imageVector = Icons.Rounded.Check,
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.textActionAccent,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
text = text,
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
InfoListOrganism(
|
||||
items = persistentListOf(
|
||||
InfoListItem(
|
||||
message = stringResource(id = R.string.screen_analytics_prompt_data_usage),
|
||||
iconComposable = { CheckIcon() },
|
||||
),
|
||||
InfoListItem(
|
||||
message = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing),
|
||||
iconComposable = { CheckIcon() },
|
||||
),
|
||||
InfoListItem(
|
||||
message = stringResource(id = R.string.screen_analytics_prompt_settings),
|
||||
iconComposable = { CheckIcon() },
|
||||
),
|
||||
),
|
||||
textStyle = ElementTheme.typography.fontBodyMdMedium,
|
||||
iconTint = ElementTheme.colors.textPrimary,
|
||||
backgroundColor = ElementTheme.colors.temporaryColorBgSpecial
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AnalyticsOptInFooter(
|
||||
eventSink: (AnalyticsOptInEvents) -> Unit,
|
||||
onTermsAccepted: () -> Unit,
|
||||
onTermsDeclined: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ButtonColumnMolecule(
|
||||
modifier = modifier,
|
||||
) {
|
||||
Button(
|
||||
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) },
|
||||
onClick = onTermsAccepted,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(text = stringResource(id = CommonStrings.action_ok))
|
||||
}
|
||||
TextButton(
|
||||
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) },
|
||||
onClick = onTermsDeclined,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(text = stringResource(id = CommonStrings.action_not_now))
|
||||
|
|
|
|||
|
|
@ -67,4 +67,8 @@ class FakeAnalyticsService(
|
|||
|
||||
override fun trackError(throwable: Throwable) {
|
||||
}
|
||||
|
||||
override suspend fun reset() {
|
||||
didAskUserConsentFlow.value = false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
features/ftue/api/build.gradle.kts
Normal file
27
features/ftue/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.ftue.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.architecture)
|
||||
}
|
||||
|
|
@ -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.api
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
|
||||
interface FtueEntryPoint : FeatureEntryPoint {
|
||||
|
||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||
|
||||
interface NodeBuilder {
|
||||
fun callback(callback: Callback): NodeBuilder
|
||||
fun build(): Node
|
||||
}
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onFtueFlowFinished()
|
||||
}
|
||||
}
|
||||
|
|
@ -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.api.state
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface FtueState {
|
||||
val shouldDisplayFlow: StateFlow<Boolean>
|
||||
|
||||
suspend fun reset()
|
||||
}
|
||||
55
features/ftue/impl/build.gradle.kts
Normal file
55
features/ftue/impl/build.gradle.kts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
alias(libs.plugins.anvil)
|
||||
alias(libs.plugins.ksp)
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.ftue.impl"
|
||||
}
|
||||
|
||||
anvil {
|
||||
generateDaggerFactories.set(true)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.anvilannotations)
|
||||
anvil(projects.anvilcodegen)
|
||||
api(projects.features.ftue.api)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(projects.features.analytics.api)
|
||||
implementation(projects.services.analytics.api)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.features.analytics.test)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* 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
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.ftue.api.FtueEntryPoint
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultFtueEntryPoint @Inject constructor() : FtueEntryPoint {
|
||||
|
||||
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): FtueEntryPoint.NodeBuilder {
|
||||
val plugins = ArrayList<Plugin>()
|
||||
|
||||
return object : FtueEntryPoint.NodeBuilder {
|
||||
|
||||
override fun callback(callback: FtueEntryPoint.Callback): FtueEntryPoint.NodeBuilder {
|
||||
plugins += callback
|
||||
return this
|
||||
}
|
||||
|
||||
override fun build(): Node {
|
||||
return parentNode.createNode<FtueFlowNode>(buildContext, plugins)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
* 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
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
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.navigation.backpresshandlerstrategies.BaseBackPressHandlerStrategy
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.newRoot
|
||||
import com.bumble.appyx.navmodel.backstack.operation.replace
|
||||
import dagger.assisted.Assisted
|
||||
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.state.DefaultFtueState
|
||||
import io.element.android.features.ftue.impl.state.FtueStep
|
||||
import io.element.android.features.ftue.impl.welcome.WelcomeNode
|
||||
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.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class FtueFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val ftueState: DefaultFtueState,
|
||||
private val analyticsEntryPoint: AnalyticsEntryPoint,
|
||||
private val analyticsService: AnalyticsService,
|
||||
) : BackstackNode<FtueFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Placeholder,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
backPressHandler = NoOpBackstackHandlerStrategy<NavTarget>(),
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
) {
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object Placeholder : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object WelcomeScreen : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object AnalyticsOptIn : NavTarget
|
||||
}
|
||||
|
||||
private val callback = plugins.filterIsInstance<FtueEntryPoint.Callback>().firstOrNull()
|
||||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
|
||||
lifecycle.subscribe(onCreate = {
|
||||
lifecycleScope.launch { moveToNextStep() }
|
||||
})
|
||||
|
||||
analyticsService.didAskUserConsent()
|
||||
.drop(1) // We only care about consent passing from not asked to asked state
|
||||
.onEach { didAskUserConsent ->
|
||||
if (didAskUserConsent) {
|
||||
lifecycleScope.launch { moveToNextStep() }
|
||||
}
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Placeholder -> {
|
||||
createNode<PlaceholderNode>(buildContext)
|
||||
}
|
||||
NavTarget.WelcomeScreen -> {
|
||||
val callback = object : WelcomeNode.Callback {
|
||||
override fun onContinueClicked() {
|
||||
ftueState.setWelcomeScreenShown()
|
||||
lifecycleScope.launch { moveToNextStep() }
|
||||
}
|
||||
}
|
||||
createNode<WelcomeNode>(buildContext, listOf(callback))
|
||||
}
|
||||
NavTarget.AnalyticsOptIn -> {
|
||||
analyticsEntryPoint.createNode(this, buildContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun moveToNextStep() {
|
||||
when (ftueState.getNextStep()) {
|
||||
is FtueStep.WelcomeScreen -> {
|
||||
backstack.newRoot(NavTarget.WelcomeScreen)
|
||||
}
|
||||
is FtueStep.AnalyticsOptIn -> {
|
||||
backstack.replace(NavTarget.AnalyticsOptIn)
|
||||
}
|
||||
null -> callback?.onFtueFlowFinished()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Children(
|
||||
navModel = backstack,
|
||||
modifier = modifier,
|
||||
transitionHandler = rememberDefaultTransitionHandler(),
|
||||
)
|
||||
}
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class PlaceholderNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
) : Node(buildContext, plugins = plugins)
|
||||
}
|
||||
|
||||
private class NoOpBackstackHandlerStrategy<NavTarget : Any> : BaseBackPressHandlerStrategy<NavTarget, BackStack.State>() {
|
||||
override val canHandleBackPressFlow: StateFlow<Boolean> = MutableStateFlow(true)
|
||||
|
||||
override fun onBackPressed() {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* 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.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.welcome.state.WelcomeScreenState
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultFtueState @Inject constructor(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val welcomeScreenState: WelcomeScreenState,
|
||||
) : FtueState {
|
||||
|
||||
override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete())
|
||||
|
||||
override suspend fun reset() {
|
||||
welcomeScreenState.reset()
|
||||
analyticsService.reset()
|
||||
}
|
||||
|
||||
init {
|
||||
analyticsService.didAskUserConsent()
|
||||
.onEach { updateState() }
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
|
||||
when (currentStep) {
|
||||
null -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep(
|
||||
FtueStep.WelcomeScreen
|
||||
)
|
||||
FtueStep.WelcomeScreen -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep(
|
||||
FtueStep.AnalyticsOptIn
|
||||
)
|
||||
FtueStep.AnalyticsOptIn -> null
|
||||
}
|
||||
|
||||
private fun isAnyStepIncomplete(): Boolean {
|
||||
return listOf(
|
||||
shouldDisplayWelcomeScreen(),
|
||||
needsAnalyticsOptIn()
|
||||
).any { it }
|
||||
}
|
||||
|
||||
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() }
|
||||
}
|
||||
|
||||
private fun shouldDisplayWelcomeScreen(): Boolean {
|
||||
return welcomeScreenState.isWelcomeScreenNeeded()
|
||||
}
|
||||
|
||||
fun setWelcomeScreenShown() {
|
||||
welcomeScreenState.setWelcomeScreenShown()
|
||||
updateState()
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun updateState() {
|
||||
shouldDisplayFlow.value = isAnyStepIncomplete()
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface FtueStep {
|
||||
object WelcomeScreen : FtueStep
|
||||
object AnalyticsOptIn : FtueStep
|
||||
}
|
||||
|
|
@ -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.welcome
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.di.AppScope
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class WelcomeNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val buildMeta: BuildMeta,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onContinueClicked()
|
||||
}
|
||||
|
||||
private fun onContinueClicked() {
|
||||
plugins.filterIsInstance<Callback>().forEach { it.onContinueClicked() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
WelcomeView(
|
||||
applicationName = buildMeta.applicationName,
|
||||
onContinueClicked = ::onContinueClicked,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
/*
|
||||
* 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.welcome
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
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.systemBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AddComment
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.NewReleases
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.ftue.impl.R
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.InfoListOrganism
|
||||
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Composable
|
||||
fun WelcomeView(
|
||||
applicationName: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onContinueClicked: () -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = onContinueClicked)
|
||||
OnBoardingPage(
|
||||
modifier = modifier
|
||||
.systemBarsPadding()
|
||||
.fillMaxSize(),
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(78.dp))
|
||||
ElementLogoAtom(size = ElementLogoAtomSize.Medium)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Text(
|
||||
modifier = Modifier.testTag(TestTags.welcomeScreenTitle),
|
||||
text = stringResource(R.string.screen_welcome_title, applicationName),
|
||||
style = ElementTheme.typography.fontHeadingMdBold,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.screen_welcome_subtitle),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
InfoListOrganism(
|
||||
items = listItems(),
|
||||
textStyle = ElementTheme.typography.fontBodyMdMedium,
|
||||
iconTint = ElementTheme.colors.iconSecondary,
|
||||
backgroundColor = ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.7f),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
},
|
||||
footer = {
|
||||
Button(modifier = Modifier.fillMaxWidth(), onClick = onContinueClicked) {
|
||||
Text(text = stringResource(CommonStrings.action_continue))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun listItems() = persistentListOf(
|
||||
InfoListItem(
|
||||
message = stringResource(R.string.screen_welcome_bullet_1),
|
||||
iconVector = Icons.Outlined.NewReleases,
|
||||
),
|
||||
InfoListItem(
|
||||
message = stringResource(R.string.screen_welcome_bullet_2),
|
||||
iconVector = Icons.Outlined.Lock,
|
||||
),
|
||||
InfoListItem(
|
||||
message = stringResource(R.string.screen_welcome_bullet_3),
|
||||
iconVector = Icons.Outlined.AddComment,
|
||||
),
|
||||
)
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
internal fun WelcomeViewPreview() {
|
||||
ElementPreview {
|
||||
WelcomeView(applicationName = "Element X", onContinueClicked = {})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.ftue.impl.welcome.state
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.DefaultPreferences
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@SingleIn(AppScope::class)
|
||||
class AndroidWelcomeScreenState @Inject constructor(
|
||||
@DefaultPreferences private val sharedPreferences: SharedPreferences,
|
||||
) : WelcomeScreenState {
|
||||
|
||||
companion object {
|
||||
private const val IS_WELCOME_SCREEN_SHOWN = "is_welcome_screen_shown"
|
||||
}
|
||||
|
||||
override fun isWelcomeScreenNeeded(): Boolean {
|
||||
return sharedPreferences.getBoolean(IS_WELCOME_SCREEN_SHOWN, false).not()
|
||||
}
|
||||
|
||||
override fun setWelcomeScreenShown() {
|
||||
sharedPreferences.edit().putBoolean(IS_WELCOME_SCREEN_SHOWN, true).apply()
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
sharedPreferences.edit {
|
||||
remove(IS_WELCOME_SCREEN_SHOWN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.ftue.impl.welcome.state
|
||||
|
||||
interface WelcomeScreenState {
|
||||
fun isWelcomeScreenNeeded(): Boolean
|
||||
fun setWelcomeScreenShown()
|
||||
fun reset()
|
||||
}
|
||||
9
features/ftue/impl/src/main/res/values/localazy.xml
Normal file
9
features/ftue/impl/src/main/res/values/localazy.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_welcome_bullet_1">"Calls, location sharing, 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>
|
||||
<string name="screen_welcome_button">"Let\'s go!"</string>
|
||||
<string name="screen_welcome_subtitle">"Here’s what you need to know:"</string>
|
||||
<string name="screen_welcome_title">"Welcome to %1$s!"</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* 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
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.analytics.test.FakeAnalyticsService
|
||||
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.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultFtueStateTests {
|
||||
|
||||
@Test
|
||||
fun `given any check being false, should display flow is true`() = runTest {
|
||||
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val state = createState(coroutineScope)
|
||||
|
||||
assertThat(state.shouldDisplayFlow.value).isTrue()
|
||||
|
||||
// Cleanup
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given all checks being true, should display flow is false`() = runTest {
|
||||
val welcomeState = FakeWelcomeState()
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
|
||||
val state = createState(coroutineScope, welcomeState, analyticsService)
|
||||
|
||||
welcomeState.setWelcomeScreenShown()
|
||||
analyticsService.setDidAskUserConsent()
|
||||
state.updateState()
|
||||
|
||||
assertThat(state.shouldDisplayFlow.value).isFalse()
|
||||
|
||||
// Cleanup
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `traverse flow`() = runTest {
|
||||
val welcomeState = FakeWelcomeState()
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
|
||||
val state = createState(coroutineScope, welcomeState, analyticsService)
|
||||
val steps = mutableListOf<FtueStep?>()
|
||||
|
||||
// First step, welcome screen
|
||||
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||
welcomeState.setWelcomeScreenShown()
|
||||
|
||||
// Second step, analytics opt in
|
||||
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||
analyticsService.setDidAskUserConsent()
|
||||
|
||||
// Final step (null)
|
||||
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||
|
||||
assertThat(steps).containsExactly(
|
||||
FtueStep.WelcomeScreen,
|
||||
FtueStep.AnalyticsOptIn,
|
||||
null, // Final state
|
||||
)
|
||||
|
||||
// Cleanup
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
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)
|
||||
|
||||
state.setWelcomeScreenShown()
|
||||
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
|
||||
|
||||
analyticsService.setDidAskUserConsent()
|
||||
assertThat(state.getNextStep(FtueStep.WelcomeScreen)).isNull()
|
||||
|
||||
// Cleanup
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
|
||||
private fun createState(
|
||||
coroutineScope: CoroutineScope,
|
||||
welcomeState: FakeWelcomeState = FakeWelcomeState(),
|
||||
analyticsService: AnalyticsService = FakeAnalyticsService()
|
||||
) = DefaultFtueState(coroutineScope, analyticsService, welcomeState)
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* 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.welcome.state
|
||||
|
||||
class FakeWelcomeState : WelcomeScreenState {
|
||||
|
||||
private var isWelcomeScreenNeeded = true
|
||||
|
||||
override fun isWelcomeScreenNeeded(): Boolean {
|
||||
return isWelcomeScreenNeeded
|
||||
}
|
||||
|
||||
override fun setWelcomeScreenShown() {
|
||||
isWelcomeScreenNeeded = false
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
isWelcomeScreenNeeded = true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_invites_decline_chat_message">"Voulez-vous vraiment refuser l‘invitation à rejoindre %1$s ?"</string>
|
||||
<string name="screen_invites_decline_chat_title">"Refuser l\'invitation"</string>
|
||||
<string name="screen_invites_decline_direct_chat_message">"Voulez-vous vraiment refuser ce chat privé avec %1$s ?"</string>
|
||||
<string name="screen_invites_decline_direct_chat_title">"Refuser le chat"</string>
|
||||
<string name="screen_invites_empty_list">"Aucune invitation"</string>
|
||||
<string name="screen_invites_invited_you">"%1$s (%2$s) vous a invité"</string>
|
||||
</resources>
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
|
|
@ -3,6 +3,8 @@
|
|||
<string name="screen_account_provider_change">"Changer de fournisseur"</string>
|
||||
<string name="screen_account_provider_continue">"Continuer"</string>
|
||||
<string name="screen_account_provider_form_hint">"Adresse du serveur d\'accueil"</string>
|
||||
<string name="screen_account_provider_form_notice">"Entrez un mot clé de recherche ou un nom de domaine."</string>
|
||||
<string name="screen_account_provider_form_subtitle">"Rechercher une entreprise, une communauté ou un serveur privé."</string>
|
||||
<string name="screen_account_provider_form_title">"Trouver un fournisseur de services"</string>
|
||||
<string name="screen_account_provider_signin_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_signin_title">"Vous êtes sur le point de vous connecter à %s"</string>
|
||||
|
|
@ -23,9 +25,23 @@
|
|||
<string name="screen_login_error_unsupported_authentication">"Le serveur domestique sélectionné ne prend pas en charge le mot de passe ou la connexion OIDC. Contactez votre administrateur ou choisissez un autre serveur domestique."</string>
|
||||
<string name="screen_login_form_header">"Saisir vos informations personnelles"</string>
|
||||
<string name="screen_login_title">"Heureux de vous revoir!"</string>
|
||||
<string name="screen_login_title_with_homeserver">"Se connecter à %1$s"</string>
|
||||
<string name="screen_server_confirmation_change_server">"Changer de fournisseur de compte"</string>
|
||||
<string name="screen_server_confirmation_message_login_element_dot_io">"Un serveur privé pour les employés d’Element."</string>
|
||||
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix est un réseau ouvert de communication sécurisée et décentralisée."</string>
|
||||
<string name="screen_server_confirmation_message_register">"C\'est là que vos conversations seront conservées — de la même manière que votre service d’e-mail habituel conserverait vos e-mails."</string>
|
||||
<string name="screen_server_confirmation_title_login">"Vous allez vous connecter à %1$s"</string>
|
||||
<string name="screen_server_confirmation_title_register">"Vous allez créer un compte sur %1$s"</string>
|
||||
<string name="screen_waitlist_message">"Il y a une forte demande pour %1$s sur %2$s en ce moment. Rouvrez l’app dans quelques jours et réessayez.
|
||||
|
||||
Merci de votre patience !"</string>
|
||||
<string name="screen_waitlist_message_success">"Bienvenue sur %1$s !"</string>
|
||||
<string name="screen_waitlist_title">"Vous y êtes presque."</string>
|
||||
<string name="screen_waitlist_title_success">"Vous y êtes."</string>
|
||||
<string name="screen_change_server_submit">"Continuer"</string>
|
||||
<string name="screen_change_server_title">"Sélectionnez votre serveur"</string>
|
||||
<string name="screen_login_password_hint">"Mot de passe"</string>
|
||||
<string name="screen_login_submit">"Continuer"</string>
|
||||
<string name="screen_login_subtitle">"Matrix est un réseau ouvert de communication sécurisée et décentralisée."</string>
|
||||
<string name="screen_login_username_hint">"Nom d\'utilisateur"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@
|
|||
<string name="screen_account_provider_form_notice">"Enter a search term or a domain address."</string>
|
||||
<string name="screen_account_provider_form_subtitle">"Search for a company, community, or private server."</string>
|
||||
<string name="screen_account_provider_form_title">"Find an account provider"</string>
|
||||
<string name="screen_account_provider_signin_subtitle">"This is where you conversations will live — just like you would use an email provider to keep your emails."</string>
|
||||
<string name="screen_account_provider_signin_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_signin_title">"You’re about to sign in to %s"</string>
|
||||
<string name="screen_account_provider_signup_subtitle">"This is where you conversations will live — just like you would use an email provider to keep your emails."</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_other">"Other"</string>
|
||||
|
|
|
|||
|
|
@ -25,4 +25,5 @@ android {
|
|||
dependencies {
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
api(projects.libraries.textcomposer)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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.api
|
||||
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
|
||||
/**
|
||||
* Hoist-able state of the message composer.
|
||||
*
|
||||
* Typical use case is inside other presenters, to know if
|
||||
* the composer is in a thread, if it's editing a message, etc.
|
||||
*/
|
||||
interface MessageComposerContext {
|
||||
val composerMode: MessageComposerMode
|
||||
}
|
||||
|
|
@ -25,7 +25,6 @@ import io.element.android.features.messages.impl.timeline.components.customreact
|
|||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.core.data.StableCharSequence
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
|
@ -48,7 +47,7 @@ fun aMessagesState() = MessagesState(
|
|||
roomAvatar = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom),
|
||||
userHasPermissionToSendMessage = true,
|
||||
composerState = aMessageComposerState().copy(
|
||||
text = StableCharSequence("Hello"),
|
||||
text = "Hello",
|
||||
isFullScreen = false,
|
||||
mode = MessageComposerMode.Normal("Hello"),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* 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.messagecomposer
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.messages.api.MessageComposerContext
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import javax.inject.Inject
|
||||
|
||||
@SingleIn(RoomScope::class)
|
||||
@ContributesBinding(RoomScope::class)
|
||||
class MessageComposerContextImpl @Inject constructor() : MessageComposerContext {
|
||||
override var composerMode: MessageComposerMode by mutableStateOf(MessageComposerMode.Normal(""))
|
||||
internal set
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ sealed interface MessageComposerEvents {
|
|||
data class SendMessage(val message: String) : MessageComposerEvents
|
||||
object CloseSpecialMode : MessageComposerEvents
|
||||
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
|
||||
data class UpdateText(val text: CharSequence) : MessageComposerEvents
|
||||
data class UpdateText(val text: String) : MessageComposerEvents
|
||||
object AddAttachment : MessageComposerEvents
|
||||
object DismissAttachmentMenu : MessageComposerEvents
|
||||
sealed interface PickAttachmentSource : MessageComposerEvents {
|
||||
|
|
|
|||
|
|
@ -34,8 +34,6 @@ import io.element.android.features.messages.impl.attachments.Attachment
|
|||
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
||||
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.data.StableCharSequence
|
||||
import io.element.android.libraries.core.data.toStableCharSequence
|
||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
|
|
@ -64,6 +62,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
private val mediaSender: MediaSender,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val messageComposerContext: MessageComposerContextImpl,
|
||||
) : Presenter<MessageComposerState> {
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
|
|
@ -93,18 +92,15 @@ class MessageComposerPresenter @Inject constructor(
|
|||
val hasFocus = remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val text: MutableState<StableCharSequence> = remember {
|
||||
mutableStateOf(StableCharSequence(""))
|
||||
}
|
||||
val composerMode: MutableState<MessageComposerMode> = rememberSaveable {
|
||||
mutableStateOf(MessageComposerMode.Normal(""))
|
||||
val text: MutableState<String> = rememberSaveable {
|
||||
mutableStateOf("")
|
||||
}
|
||||
|
||||
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(composerMode.value) {
|
||||
when (val modeValue = composerMode.value) {
|
||||
is MessageComposerMode.Edit -> text.value = modeValue.defaultContent.toStableCharSequence()
|
||||
LaunchedEffect(messageComposerContext.composerMode) {
|
||||
when (val modeValue = messageComposerContext.composerMode) {
|
||||
is MessageComposerMode.Edit -> text.value = modeValue.defaultContent
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
|
@ -122,20 +118,24 @@ class MessageComposerPresenter @Inject constructor(
|
|||
|
||||
is MessageComposerEvents.FocusChanged -> hasFocus.value = event.hasFocus
|
||||
|
||||
is MessageComposerEvents.UpdateText -> text.value = event.text.toStableCharSequence()
|
||||
is MessageComposerEvents.UpdateText -> text.value = event.text
|
||||
MessageComposerEvents.CloseSpecialMode -> {
|
||||
text.value = "".toStableCharSequence()
|
||||
composerMode.setToNormal()
|
||||
text.value = ""
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal("")
|
||||
}
|
||||
|
||||
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode, text)
|
||||
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(
|
||||
text = event.message,
|
||||
updateComposerMode = { messageComposerContext.composerMode = it },
|
||||
textState = text
|
||||
)
|
||||
is MessageComposerEvents.SetMode -> {
|
||||
composerMode.value = event.composerMode
|
||||
messageComposerContext.composerMode = event.composerMode
|
||||
analyticsService.capture(
|
||||
Composer(
|
||||
inThread = false,
|
||||
isEditing = composerMode.value is MessageComposerMode.Edit,
|
||||
isReply = composerMode.value is MessageComposerMode.Reply,
|
||||
inThread = messageComposerContext.composerMode.inThread,
|
||||
isEditing = messageComposerContext.composerMode.isEditing,
|
||||
isReply = messageComposerContext.composerMode.isReply,
|
||||
isLocation = false,
|
||||
)
|
||||
)
|
||||
|
|
@ -171,7 +171,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
text = text.value,
|
||||
isFullScreen = isFullScreen.value,
|
||||
hasFocus = hasFocus.value,
|
||||
mode = composerMode.value,
|
||||
mode = messageComposerContext.composerMode,
|
||||
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||
attachmentsState = attachmentsState.value,
|
||||
eventSink = ::handleEvents
|
||||
|
|
@ -184,31 +184,30 @@ class MessageComposerPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun MutableState<MessageComposerMode>.setToNormal() {
|
||||
value = MessageComposerMode.Normal("")
|
||||
}
|
||||
|
||||
private fun CoroutineScope.sendMessage(text: String, composerMode: MutableState<MessageComposerMode>, textState: MutableState<StableCharSequence>) =
|
||||
launch {
|
||||
val capturedMode = composerMode.value
|
||||
// Reset composer right away
|
||||
textState.value = "".toStableCharSequence()
|
||||
composerMode.setToNormal()
|
||||
when (capturedMode) {
|
||||
is MessageComposerMode.Normal -> room.sendMessage(text)
|
||||
is MessageComposerMode.Edit -> {
|
||||
val eventId = capturedMode.eventId
|
||||
val transactionId = capturedMode.transactionId
|
||||
room.editMessage(eventId, transactionId, text)
|
||||
}
|
||||
|
||||
is MessageComposerMode.Quote -> TODO()
|
||||
is MessageComposerMode.Reply -> room.replyMessage(
|
||||
capturedMode.eventId,
|
||||
text
|
||||
)
|
||||
private fun CoroutineScope.sendMessage(
|
||||
text: String,
|
||||
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,
|
||||
textState: MutableState<String>
|
||||
) = launch {
|
||||
val capturedMode = messageComposerContext.composerMode
|
||||
// Reset composer right away
|
||||
textState.value = ""
|
||||
updateComposerMode(MessageComposerMode.Normal(""))
|
||||
when (capturedMode) {
|
||||
is MessageComposerMode.Normal -> room.sendMessage(text)
|
||||
is MessageComposerMode.Edit -> {
|
||||
val eventId = capturedMode.eventId
|
||||
val transactionId = capturedMode.transactionId
|
||||
room.editMessage(eventId, transactionId, text)
|
||||
}
|
||||
|
||||
is MessageComposerMode.Quote -> TODO()
|
||||
is MessageComposerMode.Reply -> room.replyMessage(
|
||||
capturedMode.eventId,
|
||||
text
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.sendAttachment(
|
||||
attachment: Attachment,
|
||||
|
|
|
|||
|
|
@ -18,13 +18,12 @@ package io.element.android.features.messages.impl.messagecomposer
|
|||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.libraries.core.data.StableCharSequence
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
data class MessageComposerState(
|
||||
val text: StableCharSequence?,
|
||||
val text: String?,
|
||||
val isFullScreen: Boolean,
|
||||
val hasFocus: Boolean,
|
||||
val mode: MessageComposerMode,
|
||||
|
|
@ -32,7 +31,7 @@ data class MessageComposerState(
|
|||
val attachmentsState: AttachmentsState,
|
||||
val eventSink: (MessageComposerEvents) -> Unit
|
||||
) {
|
||||
val isSendButtonVisible: Boolean = text?.charSequence.isNullOrEmpty().not()
|
||||
val isSendButtonVisible: Boolean = text.isNullOrEmpty().not()
|
||||
}
|
||||
|
||||
@Immutable
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.core.data.StableCharSequence
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
|
||||
open class MessageComposerStateProvider : PreviewParameterProvider<MessageComposerState> {
|
||||
|
|
@ -28,7 +27,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
|
|||
}
|
||||
|
||||
fun aMessageComposerState() = MessageComposerState(
|
||||
text = StableCharSequence(""),
|
||||
text = "",
|
||||
isFullScreen = false,
|
||||
hasFocus = false,
|
||||
mode = MessageComposerMode.Normal(content = ""),
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ fun MessageComposerView(
|
|||
state.eventSink(MessageComposerEvents.CloseSpecialMode)
|
||||
}
|
||||
|
||||
fun onComposerTextChange(text: CharSequence) {
|
||||
fun onComposerTextChange(text: String) {
|
||||
state.eventSink(MessageComposerEvents.UpdateText(text))
|
||||
}
|
||||
|
||||
|
|
@ -69,7 +69,7 @@ fun MessageComposerView(
|
|||
onAddAttachment = ::onAddAttachment,
|
||||
onFocusChanged = ::onFocusChanged,
|
||||
composerCanSendMessage = state.isSendButtonVisible,
|
||||
composerText = state.text?.charSequence?.toString(),
|
||||
composerText = state.text,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,21 +22,24 @@ import androidx.compose.runtime.MutableState
|
|||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||
import io.element.android.libraries.matrix.ui.room.canSendEventAsState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val backPaginationEventLimit = 20
|
||||
|
|
@ -45,42 +48,52 @@ private const val backPaginationPageSize = 50
|
|||
class TimelinePresenter @Inject constructor(
|
||||
private val timelineItemsFactory: TimelineItemsFactory,
|
||||
private val room: MatrixRoom,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val appScope: CoroutineScope,
|
||||
) : Presenter<TimelineState> {
|
||||
|
||||
private val timeline = room.timeline
|
||||
|
||||
@Composable
|
||||
override fun present(): TimelineState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val localScope = rememberCoroutineScope()
|
||||
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
|
||||
mutableStateOf(null)
|
||||
}
|
||||
|
||||
var lastReadMarkerIndex by rememberSaveable { mutableStateOf(Int.MAX_VALUE) }
|
||||
var lastReadMarkerId by rememberSaveable { mutableStateOf<EventId?>(null) }
|
||||
val lastReadReceiptIndex = rememberSaveable { mutableStateOf(Int.MAX_VALUE) }
|
||||
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
|
||||
|
||||
val timelineItems by timelineItemsFactory.collectItemsAsState()
|
||||
val paginationState by timeline.paginationState.collectAsState()
|
||||
|
||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||
val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
||||
|
||||
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val hasNewItems = remember { mutableStateOf(false) }
|
||||
|
||||
fun handleEvents(event: TimelineEvents) {
|
||||
when (event) {
|
||||
TimelineEvents.LoadMore -> localCoroutineScope.paginateBackwards()
|
||||
TimelineEvents.LoadMore -> localScope.paginateBackwards()
|
||||
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
|
||||
is TimelineEvents.OnScrollFinished -> {
|
||||
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
|
||||
val eventId = getLastEventIdBeforeOrAt(event.firstIndex, timelineItems) ?: return
|
||||
if (event.firstIndex <= lastReadMarkerIndex && eventId != lastReadMarkerId) {
|
||||
lastReadMarkerIndex = event.firstIndex
|
||||
lastReadMarkerId = eventId
|
||||
localCoroutineScope.sendReadReceipt(eventId)
|
||||
if (event.firstIndex == 0) {
|
||||
hasNewItems.value = false
|
||||
}
|
||||
appScope.sendReadReceiptIfNeeded(
|
||||
firstVisibleIndex = event.firstIndex,
|
||||
timelineItems = timelineItems,
|
||||
lastReadReceiptIndex = lastReadReceiptIndex,
|
||||
lastReadReceiptId = lastReadReceiptId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(timelineItems.size) {
|
||||
computeHasNewItems(timelineItems, prevMostRecentItemId, hasNewItems)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
timeline
|
||||
.timelineItems
|
||||
|
|
@ -98,10 +111,49 @@ class TimelinePresenter @Inject constructor(
|
|||
canReply = userHasPermissionToSendMessage,
|
||||
paginationState = paginationState,
|
||||
timelineItems = timelineItems,
|
||||
hasNewItems = hasNewItems.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* This method compute the hasNewItem state passed as a [MutableState] each time the timeline items size changes.
|
||||
* Basically, if we got new timeline event from sync or local, either from us or another user, we update the state so we tell we have new items.
|
||||
* The state never goes back to false from this method, but need to be reset from somewhere else.
|
||||
*/
|
||||
private suspend fun computeHasNewItems(
|
||||
timelineItems: ImmutableList<TimelineItem>,
|
||||
prevMostRecentItemId: MutableState<String?>,
|
||||
hasNewItemsState: MutableState<Boolean>
|
||||
) = withContext(dispatchers.computation) {
|
||||
val newMostRecentItem = timelineItems.firstOrNull()
|
||||
val prevMostRecentItemIdValue = prevMostRecentItemId.value
|
||||
val newMostRecentItemId = newMostRecentItem?.identifier()
|
||||
val hasNewItems = prevMostRecentItemIdValue != null &&
|
||||
newMostRecentItem is TimelineItem.Event &&
|
||||
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
|
||||
newMostRecentItemId != prevMostRecentItemIdValue
|
||||
if (hasNewItems) {
|
||||
hasNewItemsState.value = true
|
||||
}
|
||||
prevMostRecentItemId.value = newMostRecentItemId
|
||||
}
|
||||
|
||||
private fun CoroutineScope.sendReadReceiptIfNeeded(
|
||||
firstVisibleIndex: Int,
|
||||
timelineItems: ImmutableList<TimelineItem>,
|
||||
lastReadReceiptIndex: MutableState<Int>,
|
||||
lastReadReceiptId: MutableState<EventId?>,
|
||||
) = launch(dispatchers.computation) {
|
||||
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
|
||||
val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)
|
||||
if (eventId != null && firstVisibleIndex <= lastReadReceiptIndex.value && eventId != lastReadReceiptId.value) {
|
||||
lastReadReceiptIndex.value = firstVisibleIndex
|
||||
lastReadReceiptId.value = eventId
|
||||
timeline.sendReadReceipt(eventId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLastEventIdBeforeOrAt(index: Int, items: ImmutableList<TimelineItem>): EventId? {
|
||||
for (item in items.subList(index, items.count())) {
|
||||
if (item is TimelineItem.Event) {
|
||||
|
|
@ -114,8 +166,4 @@ class TimelinePresenter @Inject constructor(
|
|||
private fun CoroutineScope.paginateBackwards() = launch {
|
||||
timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.sendReadReceipt(eventId: EventId) = launch {
|
||||
timeline.sendReadReceipt(eventId)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,5 +28,6 @@ data class TimelineState(
|
|||
val highlightedEventId: EventId?,
|
||||
val canReply: Boolean,
|
||||
val paginationState: MatrixTimeline.PaginationState,
|
||||
val hasNewItems: Boolean,
|
||||
val eventSink: (TimelineEvents) -> Unit
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,11 +27,12 @@ import io.element.android.features.messages.impl.timeline.model.virtual.aTimelin
|
|||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
|
@ -44,7 +45,8 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf
|
|||
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true),
|
||||
highlightedEventId = null,
|
||||
canReply = true,
|
||||
eventSink = {}
|
||||
hasNewItems = false,
|
||||
eventSink = {},
|
||||
)
|
||||
|
||||
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
|
||||
|
|
@ -102,7 +104,7 @@ fun aTimelineItemDaySeparator(): TimelineItem.Virtual {
|
|||
|
||||
internal fun aTimelineItemEvent(
|
||||
eventId: EventId = EventId("\$" + Random.nextInt().toString()),
|
||||
transactionId: String? = null,
|
||||
transactionId: TransactionId? = null,
|
||||
isMine: Boolean = false,
|
||||
content: TimelineItemEventContent = aTimelineItemTextContent(),
|
||||
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
||||
|
|
@ -126,6 +128,7 @@ internal fun aTimelineItemEvent(
|
|||
localSendState = sendState,
|
||||
inReplyTo = inReplyTo,
|
||||
debugInfo = debugInfo,
|
||||
origin = null
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -152,13 +155,14 @@ internal fun aTimelineItemDebugInfo(
|
|||
model, originalJson, latestEditedJson
|
||||
)
|
||||
|
||||
fun aGroupedEvents(): TimelineItem.GroupedEvents {
|
||||
fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents {
|
||||
val event = aTimelineItemEvent(
|
||||
isMine = true,
|
||||
content = aTimelineItemStateEventContent(),
|
||||
groupPosition = TimelineItemGroupPosition.None
|
||||
)
|
||||
return TimelineItem.GroupedEvents(
|
||||
id = id.toString(),
|
||||
events = listOf(
|
||||
event,
|
||||
event,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ package io.element.android.features.messages.impl.timeline
|
|||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -48,7 +49,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
|
@ -71,7 +71,6 @@ import io.element.android.libraries.designsystem.theme.components.Icon
|
|||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
|
|
@ -100,13 +99,6 @@ fun TimelineView(
|
|||
// TODO implement this logic once we have support to 'jump to event X' in sliding sync
|
||||
}
|
||||
|
||||
// Send an event to the presenter when the scrolling is finished, with the first visible index at the bottom.
|
||||
val firstVisibleIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
|
||||
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
|
||||
LaunchedEffect(firstVisibleIndex, isScrollFinished) {
|
||||
if (!isScrollFinished) return@LaunchedEffect
|
||||
state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex))
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
LazyColumn(
|
||||
|
|
@ -147,8 +139,8 @@ fun TimelineView(
|
|||
|
||||
TimelineScrollHelper(
|
||||
lazyListState = lazyListState,
|
||||
timelineItems = state.timelineItems,
|
||||
onScrollFinishedAt = ::onScrollFinishedAt,
|
||||
hasNewItems = state.hasNewItems,
|
||||
onScrollFinishedAt = ::onScrollFinishedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -244,63 +236,66 @@ fun TimelineItemRow(
|
|||
}
|
||||
|
||||
@Composable
|
||||
internal fun BoxScope.TimelineScrollHelper(
|
||||
private fun BoxScope.TimelineScrollHelper(
|
||||
lazyListState: LazyListState,
|
||||
timelineItems: ImmutableList<TimelineItem>,
|
||||
onScrollFinishedAt: (Int) -> Unit = {},
|
||||
hasNewItems: Boolean,
|
||||
onScrollFinishedAt: (Int) -> Unit,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
|
||||
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
|
||||
val shouldAutoScrollToBottom by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 2 } }
|
||||
val showScrollToBottomButton by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 0 } }
|
||||
val canAutoScroll by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 3 } }
|
||||
|
||||
LaunchedEffect(timelineItems, firstVisibleItemIndex) {
|
||||
if (!isScrollFinished) return@LaunchedEffect
|
||||
|
||||
// Auto-scroll when new timeline items appear
|
||||
if (shouldAutoScrollToBottom) {
|
||||
LaunchedEffect(canAutoScroll, hasNewItems) {
|
||||
val shouldAutoScroll = isScrollFinished && canAutoScroll && hasNewItems
|
||||
if (shouldAutoScroll) {
|
||||
coroutineScope.launch {
|
||||
lazyListState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(isScrollFinished) {
|
||||
if (!isScrollFinished) return@LaunchedEffect
|
||||
|
||||
// Notify the parent composable about the first visible item index when scrolling finishes
|
||||
onScrollFinishedAt(firstVisibleItemIndex)
|
||||
LaunchedEffect(isScrollFinished) {
|
||||
if (isScrollFinished) {
|
||||
// Notify the parent composable about the first visible item index when scrolling finishes
|
||||
onScrollFinishedAt(lazyListState.firstVisibleItemIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// Jump to bottom button (display also in previews)
|
||||
AnimatedVisibility(
|
||||
JumpToBottomButton(
|
||||
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
|
||||
isVisible = !canAutoScroll,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 24.dp, bottom = 12.dp),
|
||||
visible = showScrollToBottomButton || LocalInspectionMode.current,
|
||||
enter = scaleIn(),
|
||||
exit = scaleOut(),
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
if (lazyListState.firstVisibleItemIndex > 10) {
|
||||
lazyListState.scrollToItem(0)
|
||||
} else {
|
||||
lazyListState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun JumpToBottomButton(
|
||||
isVisible: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
modifier = modifier,
|
||||
visible = isVisible || LocalInspectionMode.current,
|
||||
enter = scaleIn(animationSpec = tween(100)),
|
||||
exit = scaleOut(animationSpec = tween(100)),
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
if (firstVisibleItemIndex > 10) {
|
||||
lazyListState.scrollToItem(0)
|
||||
} else {
|
||||
lazyListState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = onClick,
|
||||
elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp),
|
||||
shape = CircleShape,
|
||||
modifier = Modifier
|
||||
.shadow(
|
||||
elevation = 4.dp,
|
||||
shape = CircleShape,
|
||||
ambientColor = ElementTheme.materialColors.primary,
|
||||
spotColor = ElementTheme.materialColors.primary,
|
||||
)
|
||||
.size(36.dp),
|
||||
modifier = Modifier.size(36.dp),
|
||||
containerColor = ElementTheme.colors.bgSubtleSecondary,
|
||||
contentColor = ElementTheme.colors.iconSecondary
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -18,10 +18,11 @@ package io.element.android.features.messages.impl.timeline.components
|
|||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineEncryptedHistoryBannerView
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||
|
||||
@Composable
|
||||
|
|
@ -32,5 +33,7 @@ fun TimelineItemVirtualRow(
|
|||
when (virtual.model) {
|
||||
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
|
||||
TimelineItemReadMarkerModel -> return
|
||||
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.messages.impl.timeline.components.virtual
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
@Composable
|
||||
fun TimelineEncryptedHistoryBannerView(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 32.dp)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.border(1.dp, ElementTheme.colors.borderInfoSubtle, MaterialTheme.shapes.small)
|
||||
.background(ElementTheme.colors.bgInfoSubtle)
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = "Info",
|
||||
tint = ElementTheme.colors.iconInfoPrimary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.screen_room_encrypted_history_banner),
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
color = ElementTheme.colors.textInfoPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
internal fun TimelineEncryptedHistoryBannerViewPreview() {
|
||||
ElementTheme {
|
||||
TimelineEncryptedHistoryBannerView()
|
||||
}
|
||||
}
|
||||
|
|
@ -45,7 +45,6 @@ class TimelineItemsFactory @Inject constructor(
|
|||
private val virtualItemFactory: TimelineItemVirtualFactory,
|
||||
private val timelineItemGrouper: TimelineItemGrouper,
|
||||
) {
|
||||
|
||||
private val timelineItems = MutableStateFlow(persistentListOf<TimelineItem>())
|
||||
private val timelineItemsCache = arrayListOf<TimelineItem?>()
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ class TimelineItemEventFactory @Inject constructor(
|
|||
localSendState = currentTimelineItem.event.localSendState,
|
||||
inReplyTo = currentTimelineItem.event.inReplyTo(),
|
||||
debugInfo = currentTimelineItem.event.debugInfo,
|
||||
origin = currentTimelineItem.event.origin,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package io.element.android.features.messages.impl.timeline.factories.virtual
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
|
|
@ -30,8 +31,13 @@ class TimelineItemVirtualFactory @Inject constructor(
|
|||
fun create(
|
||||
virtualTimelineItem: MatrixTimelineItem.Virtual,
|
||||
): TimelineItem.Virtual {
|
||||
val id = if (virtualTimelineItem.virtual is VirtualTimelineItem.EncryptedHistoryBanner) {
|
||||
"encrypted_history_banner"
|
||||
} else {
|
||||
virtualTimelineItem.uniqueId.toString()
|
||||
}
|
||||
return TimelineItem.Virtual(
|
||||
id = virtualTimelineItem.uniqueId.toString(),
|
||||
id = id,
|
||||
model = virtualTimelineItem.computeModel()
|
||||
)
|
||||
}
|
||||
|
|
@ -40,6 +46,7 @@ class TimelineItemVirtualFactory @Inject constructor(
|
|||
return when (val inner = virtual) {
|
||||
is VirtualTimelineItem.DayDivider -> daySeparatorFactory.create(inner)
|
||||
is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel
|
||||
is VirtualTimelineItem.EncryptedHistoryBanner -> TimelineItemEncryptedHistoryBannerVirtualModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,22 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.groups
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import javax.inject.Inject
|
||||
|
||||
@SingleIn(RoomScope::class)
|
||||
class TimelineItemGrouper @Inject constructor() {
|
||||
|
||||
/**
|
||||
* Keys are identifier of items in a group, only one by group will be kept.
|
||||
* Values are the actual groupIds.
|
||||
*/
|
||||
private val groupIds = HashMap<String, String>()
|
||||
|
||||
/**
|
||||
* Create a new list of [TimelineItem] by grouping some of them into [TimelineItem.GroupedEvents].
|
||||
*/
|
||||
|
|
@ -34,14 +45,14 @@ class TimelineItemGrouper @Inject constructor() {
|
|||
// timelineItem cannot be grouped
|
||||
if (currentGroup.isNotEmpty()) {
|
||||
// There is a pending group, create a TimelineItem.GroupedEvents if there is more than 1 Event in the pending group.
|
||||
result.addGroup(currentGroup)
|
||||
result.addGroup(groupIds, currentGroup)
|
||||
currentGroup.clear()
|
||||
}
|
||||
result.add(timelineItem)
|
||||
}
|
||||
}
|
||||
if (currentGroup.isNotEmpty()) {
|
||||
result.addGroup(currentGroup)
|
||||
result.addGroup(groupIds, currentGroup)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -51,16 +62,36 @@ class TimelineItemGrouper @Inject constructor() {
|
|||
* Will add a group if there is more than 1 item, else add the item to the list.
|
||||
*/
|
||||
private fun MutableList<TimelineItem>.addGroup(
|
||||
group: MutableList<TimelineItem.Event>
|
||||
groupIds: MutableMap<String, String>,
|
||||
groupOfItems: MutableList<TimelineItem.Event>
|
||||
) {
|
||||
if (group.size == 1) {
|
||||
if (groupOfItems.size == 1) {
|
||||
// Do not create a group with just 1 item, just add the item to the result
|
||||
add(group.first())
|
||||
add(groupOfItems.first())
|
||||
} else {
|
||||
val groupId = groupIds.getOrPutGroupId(groupOfItems)
|
||||
add(
|
||||
TimelineItem.GroupedEvents(
|
||||
events = group.toImmutableList()
|
||||
id = groupId,
|
||||
events = groupOfItems.toImmutableList()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableMap<String, String>.getOrPutGroupId(timelineItems: List<TimelineItem>): String {
|
||||
assert(timelineItems.isNotEmpty())
|
||||
for (item in timelineItems) {
|
||||
val itemIdentifier = item.identifier()
|
||||
if (this.contains(itemIdentifier)) {
|
||||
return this[itemIdentifier]!!
|
||||
}
|
||||
}
|
||||
val timelineItem = timelineItems.first()
|
||||
return computeGroupIdWith(timelineItem).also { groupId ->
|
||||
this[timelineItem.identifier()] = groupId
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun computeGroupIdWith(timelineItem: TimelineItem): String = "${timelineItem.identifier()}_group"
|
||||
|
|
|
|||
|
|
@ -22,10 +22,12 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
|
|
@ -53,7 +55,7 @@ sealed interface TimelineItem {
|
|||
data class Event(
|
||||
val id: String,
|
||||
val eventId: EventId? = null,
|
||||
val transactionId: String? = null,
|
||||
val transactionId: TransactionId? = null,
|
||||
val senderId: UserId,
|
||||
val senderDisplayName: String?,
|
||||
val senderAvatar: AvatarData,
|
||||
|
|
@ -65,6 +67,7 @@ sealed interface TimelineItem {
|
|||
val localSendState: LocalEventSendState?,
|
||||
val inReplyTo: InReplyTo?,
|
||||
val debugInfo: TimelineItemDebugInfo,
|
||||
val origin: TimelineItemEventOrigin?,
|
||||
) : TimelineItem {
|
||||
|
||||
val showSenderInformation = groupPosition.isNew() && !isMine
|
||||
|
|
@ -80,9 +83,8 @@ sealed interface TimelineItem {
|
|||
|
||||
@Immutable
|
||||
data class GroupedEvents(
|
||||
val id: String,
|
||||
val events: ImmutableList<Event>,
|
||||
) : TimelineItem {
|
||||
// use last id with a suffix. Last will not change in cas of new event from backpagination.
|
||||
val id = "${events.last().id}_group"
|
||||
}
|
||||
) : TimelineItem
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* 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.model.virtual
|
||||
|
||||
object TimelineItemEncryptedHistoryBannerVirtualModel : TimelineItemVirtualModel {
|
||||
override val type: String = "TimelineItemEncryptedHistoryBannerVirtualModel"
|
||||
}
|
||||
|
|
@ -4,10 +4,27 @@
|
|||
<item quantity="one">"%1$d changement dans la conversation"</item>
|
||||
<item quantity="other">"%1$d changements dans la conversation"</item>
|
||||
</plurals>
|
||||
<plurals name="screen_room_timeline_more_reactions">
|
||||
<item quantity="one"></item>
|
||||
<item quantity="other">"%1$d de plus"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_attachment_source_camera">"Appareil photo"</string>
|
||||
<string name="screen_room_attachment_source_camera_photo">"Prendre une photo"</string>
|
||||
<string name="screen_room_attachment_source_camera_video">"Enregistrer une vidéo"</string>
|
||||
<string name="screen_room_attachment_source_files">"Pièce-jointe"</string>
|
||||
<string name="screen_room_attachment_source_gallery">"Gallerie photo et vidéo"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"L’historique des messages n’est pas disponible actuellement dans ce salon"</string>
|
||||
<string name="screen_room_error_failed_retrieving_user_details">"Impossible de récupérer les détails de l’utilisateur"</string>
|
||||
<string name="screen_room_invite_again_alert_message">"Souhaitez-vous les inviter à revenir ?"</string>
|
||||
<string name="screen_room_invite_again_alert_title">"Vous êtes seul dans ce chat"</string>
|
||||
<string name="screen_room_message_copied">"Message copié"</string>
|
||||
<string name="screen_room_no_permission_to_post">"Vous n‘avez pas le droit de poster dans ce salon"</string>
|
||||
<string name="screen_room_reactions_show_less">"Afficher moins"</string>
|
||||
<string name="screen_room_reactions_show_more">"Afficher plus"</string>
|
||||
<string name="screen_room_retry_send_menu_send_again_action">"Renvoyer"</string>
|
||||
<string name="screen_room_retry_send_menu_title">"Votre message n\'a pas pu être envoyé"</string>
|
||||
<string name="screen_room_timeline_add_reaction">"Ajouter un emoji"</string>
|
||||
<string name="screen_room_timeline_less_reactions">"Montrer moins"</string>
|
||||
<string name="screen_room_error_failed_processing_media">"Échec du traitement du média avant son envoi, veuillez réessayer."</string>
|
||||
<string name="screen_room_retry_send_menu_remove_action">"Supprimer"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@
|
|||
<item quantity="few">"%1$d zmeny miestnosti"</item>
|
||||
<item quantity="other">"%1$d zmien miestnosti"</item>
|
||||
</plurals>
|
||||
<plurals name="screen_room_timeline_more_reactions">
|
||||
<item quantity="one"></item>
|
||||
<item quantity="few">"%1$d ďalšie"</item>
|
||||
<item quantity="other">"%1$d ďalších"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_attachment_source_camera">"Kamera"</string>
|
||||
<string name="screen_room_attachment_source_camera_photo">"Odfotiť"</string>
|
||||
<string name="screen_room_attachment_source_camera_video">"Nahrať video"</string>
|
||||
|
|
@ -21,6 +26,8 @@
|
|||
<string name="screen_room_reactions_show_more">"Zobraziť viac"</string>
|
||||
<string name="screen_room_retry_send_menu_send_again_action">"Odoslať znova"</string>
|
||||
<string name="screen_room_retry_send_menu_title">"Vašu správu sa nepodarilo odoslať"</string>
|
||||
<string name="screen_room_timeline_add_reaction">"Pridať emoji"</string>
|
||||
<string name="screen_room_timeline_less_reactions">"Zobraziť menej"</string>
|
||||
<string name="screen_room_error_failed_processing_media">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string>
|
||||
<string name="screen_room_retry_send_menu_remove_action">"Odstrániť"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,17 @@
|
|||
<string name="screen_room_invite_again_alert_title">"You are alone in this chat"</string>
|
||||
<string name="screen_room_message_copied">"Message copied"</string>
|
||||
<string name="screen_room_no_permission_to_post">"You do not have permission to post to this room"</string>
|
||||
<string name="screen_room_notification_settings_allow_custom">"Allow custom setting"</string>
|
||||
<string name="screen_room_notification_settings_allow_custom_footnote">"Turning this on will override your default setting"</string>
|
||||
<string name="screen_room_notification_settings_custom_settings_title">"Notify me in this chat for"</string>
|
||||
<string name="screen_room_notification_settings_default_setting_footnote">"You can change it in your %1$s."</string>
|
||||
<string name="screen_room_notification_settings_default_setting_footnote_content_link">"global settings"</string>
|
||||
<string name="screen_room_notification_settings_default_setting_title">"Default setting"</string>
|
||||
<string name="screen_room_notification_settings_error_loading_settings">"An error occurred while loading notification settings."</string>
|
||||
<string name="screen_room_notification_settings_error_restoring_default">"Failed restoring the default mode, please try again."</string>
|
||||
<string name="screen_room_notification_settings_error_setting_mode">"Failed setting the mode, please try again."</string>
|
||||
<string name="screen_room_notification_settings_mode_all_messages">"All messages"</string>
|
||||
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions and Keywords only"</string>
|
||||
<string name="screen_room_reactions_show_less">"Show less"</string>
|
||||
<string name="screen_room_reactions_show_more">"Show more"</string>
|
||||
<string name="screen_room_retry_send_menu_send_again_action">"Send again"</string>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.MessagesPresenter
|
|||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||
|
|
@ -568,10 +569,13 @@ class MessagesPresenterTest {
|
|||
mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom),
|
||||
snackbarDispatcher = SnackbarDispatcher(),
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
messageComposerContext = MessageComposerContextImpl(),
|
||||
)
|
||||
val timelinePresenter = TimelinePresenter(
|
||||
timelineItemsFactory = aTimelineItemsFactory(),
|
||||
room = matrixRoom,
|
||||
dispatchers = coroutineDispatchers,
|
||||
appScope = this
|
||||
)
|
||||
val buildMeta = aBuildMeta()
|
||||
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
|
||||
|
|
|
|||
|
|
@ -52,4 +52,5 @@ internal fun aMessageEvent(
|
|||
localSendState = sendState,
|
||||
inReplyTo = inReplyTo,
|
||||
debugInfo = debugInfo,
|
||||
origin = null
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,17 +26,18 @@ import app.cash.turbine.test
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||
import io.element.android.libraries.core.data.StableCharSequence
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
|
|
@ -82,7 +83,7 @@ class MessageComposerPresenterTest {
|
|||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.isFullScreen).isFalse()
|
||||
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
|
||||
assertThat(initialState.text).isEqualTo("")
|
||||
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
||||
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
||||
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
|
||||
|
|
@ -115,11 +116,11 @@ class MessageComposerPresenterTest {
|
|||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
|
||||
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
|
||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
||||
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(""))
|
||||
val withEmptyMessageState = awaitItem()
|
||||
assertThat(withEmptyMessageState.text).isEqualTo(StableCharSequence(""))
|
||||
assertThat(withEmptyMessageState.text).isEqualTo("")
|
||||
assertThat(withEmptyMessageState.isSendButtonVisible).isFalse()
|
||||
}
|
||||
}
|
||||
|
|
@ -136,7 +137,7 @@ class MessageComposerPresenterTest {
|
|||
state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(mode)
|
||||
state = awaitItem()
|
||||
assertThat(state.text).isEqualTo(StableCharSequence(A_MESSAGE))
|
||||
assertThat(state.text).isEqualTo(A_MESSAGE)
|
||||
assertThat(state.isSendButtonVisible).isTrue()
|
||||
backToNormalMode(state, skipCount = 1)
|
||||
}
|
||||
|
|
@ -153,7 +154,7 @@ class MessageComposerPresenterTest {
|
|||
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(mode)
|
||||
assertThat(state.text).isEqualTo(StableCharSequence(""))
|
||||
assertThat(state.text).isEqualTo("")
|
||||
assertThat(state.isSendButtonVisible).isFalse()
|
||||
backToNormalMode(state)
|
||||
}
|
||||
|
|
@ -170,7 +171,7 @@ class MessageComposerPresenterTest {
|
|||
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(mode)
|
||||
assertThat(state.text).isEqualTo(StableCharSequence(""))
|
||||
assertThat(state.text).isEqualTo("")
|
||||
assertThat(state.isSendButtonVisible).isFalse()
|
||||
backToNormalMode(state)
|
||||
}
|
||||
|
|
@ -185,11 +186,11 @@ class MessageComposerPresenterTest {
|
|||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
|
||||
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
|
||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
||||
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE))
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
|
||||
assertThat(messageSentState.text).isEqualTo("")
|
||||
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
||||
}
|
||||
}
|
||||
|
|
@ -205,21 +206,21 @@ class MessageComposerPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
|
||||
assertThat(initialState.text).isEqualTo("")
|
||||
val mode = anEditMode()
|
||||
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
skipItems(1)
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.mode).isEqualTo(mode)
|
||||
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
|
||||
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
|
||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
||||
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
|
||||
val withEditedMessageState = awaitItem()
|
||||
assertThat(withEditedMessageState.text).isEqualTo(StableCharSequence(ANOTHER_MESSAGE))
|
||||
assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE)
|
||||
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
|
||||
skipItems(1)
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
|
||||
assertThat(messageSentState.text).isEqualTo("")
|
||||
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
||||
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
|
||||
}
|
||||
|
|
@ -236,21 +237,21 @@ class MessageComposerPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
|
||||
assertThat(initialState.text).isEqualTo("")
|
||||
val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID)
|
||||
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
skipItems(1)
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.mode).isEqualTo(mode)
|
||||
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
|
||||
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
|
||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
||||
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
|
||||
val withEditedMessageState = awaitItem()
|
||||
assertThat(withEditedMessageState.text).isEqualTo(StableCharSequence(ANOTHER_MESSAGE))
|
||||
assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE)
|
||||
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
|
||||
skipItems(1)
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
|
||||
assertThat(messageSentState.text).isEqualTo("")
|
||||
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
||||
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
|
||||
}
|
||||
|
|
@ -267,21 +268,21 @@ class MessageComposerPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
|
||||
assertThat(initialState.text).isEqualTo("")
|
||||
val mode = aReplyMode()
|
||||
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
val state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(mode)
|
||||
assertThat(state.text).isEqualTo(StableCharSequence(""))
|
||||
assertThat(state.text).isEqualTo("")
|
||||
assertThat(state.isSendButtonVisible).isFalse()
|
||||
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_REPLY))
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_REPLY))
|
||||
assertThat(withMessageState.text).isEqualTo(A_REPLY)
|
||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
||||
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY))
|
||||
skipItems(1)
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
|
||||
assertThat(messageSentState.text).isEqualTo("")
|
||||
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
||||
assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY)
|
||||
}
|
||||
|
|
@ -484,7 +485,7 @@ class MessageComposerPresenterTest {
|
|||
skipItems(skipCount)
|
||||
val normalState = awaitItem()
|
||||
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
||||
assertThat(normalState.text).isEqualTo(StableCharSequence(""))
|
||||
assertThat(normalState.text).isEqualTo("")
|
||||
assertThat(normalState.isSendButtonVisible).isFalse()
|
||||
}
|
||||
|
||||
|
|
@ -503,14 +504,15 @@ class MessageComposerPresenterTest {
|
|||
localMediaFactory,
|
||||
MediaSender(mediaPreProcessor, room),
|
||||
snackbarDispatcher,
|
||||
FakeAnalyticsService()
|
||||
FakeAnalyticsService(),
|
||||
MessageComposerContextImpl(),
|
||||
)
|
||||
}
|
||||
|
||||
fun anEditMode(
|
||||
eventId: EventId? = AN_EVENT_ID,
|
||||
message: String = A_MESSAGE,
|
||||
transactionId: String? = null,
|
||||
transactionId: TransactionId? = null,
|
||||
) = MessageComposerMode.Edit(eventId, message, transactionId)
|
||||
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE)
|
||||
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)
|
||||
|
|
|
|||
|
|
@ -23,22 +23,25 @@ import com.google.common.truth.Truth.assertThat
|
|||
import io.element.android.features.messages.fixtures.aTimelineItemsFactory
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aMessageContent
|
||||
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
|
||||
import io.element.android.tests.testutils.awaitWithLatch
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class TimelinePresenterTest {
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = TimelinePresenter(
|
||||
timelineItemsFactory = aTimelineItemsFactory(),
|
||||
room = FakeMatrixRoom(),
|
||||
)
|
||||
val presenter = createTimelinePresenter()
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
|
@ -51,10 +54,7 @@ class TimelinePresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - load more`() = runTest {
|
||||
val presenter = TimelinePresenter(
|
||||
timelineItemsFactory = aTimelineItemsFactory(),
|
||||
room = FakeMatrixRoom(),
|
||||
)
|
||||
val presenter = createTimelinePresenter()
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
|
@ -73,10 +73,7 @@ class TimelinePresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - set highlighted event`() = runTest {
|
||||
val presenter = TimelinePresenter(
|
||||
timelineItemsFactory = aTimelineItemsFactory(),
|
||||
room = FakeMatrixRoom(),
|
||||
)
|
||||
val presenter = createTimelinePresenter()
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
|
@ -94,70 +91,112 @@ class TimelinePresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
|
||||
val timeline = FakeMatrixTimeline()
|
||||
val timelineItemsFactory = aTimelineItemsFactory().apply {
|
||||
replaceWith(listOf(MatrixTimelineItem.Event(0, anEventTimelineItem())))
|
||||
}
|
||||
val room = FakeMatrixRoom(matrixTimeline = timeline)
|
||||
val presenter = TimelinePresenter(
|
||||
timelineItemsFactory = timelineItemsFactory,
|
||||
room = room,
|
||||
val timeline = FakeMatrixTimeline(
|
||||
initialTimelineItems = listOf(
|
||||
MatrixTimelineItem.Event(0, anEventTimelineItem())
|
||||
)
|
||||
)
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
|
||||
val initialState = awaitItem()
|
||||
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
||||
|
||||
// Wait for timeline items to be populated
|
||||
skipItems(1)
|
||||
awaitWithLatch { latch ->
|
||||
timeline.sendReadReceiptLatch = latch
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
||||
}
|
||||
assertThat(timeline.sendReadReceiptCount).isEqualTo(1)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - on scroll finished will not send read receipt no event is before the index`() = runTest {
|
||||
val timeline = FakeMatrixTimeline()
|
||||
val timelineItemsFactory = aTimelineItemsFactory().apply {
|
||||
replaceWith(listOf(MatrixTimelineItem.Event(0, anEventTimelineItem())))
|
||||
}
|
||||
val room = FakeMatrixRoom(matrixTimeline = timeline)
|
||||
val presenter = TimelinePresenter(
|
||||
timelineItemsFactory = timelineItemsFactory,
|
||||
room = room,
|
||||
val timeline = FakeMatrixTimeline(
|
||||
initialTimelineItems = listOf(
|
||||
MatrixTimelineItem.Event(0, anEventTimelineItem())
|
||||
)
|
||||
)
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
|
||||
val initialState = awaitItem()
|
||||
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
|
||||
// Wait for timeline items to be populated
|
||||
skipItems(1)
|
||||
awaitWithLatch { latch ->
|
||||
timeline.sendReadReceiptLatch = latch
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
}
|
||||
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest {
|
||||
val timeline = FakeMatrixTimeline()
|
||||
val timelineItemsFactory = aTimelineItemsFactory().apply {
|
||||
replaceWith(listOf(MatrixTimelineItem.Virtual(0, VirtualTimelineItem.ReadMarker)))
|
||||
}
|
||||
val room = FakeMatrixRoom(matrixTimeline = timeline)
|
||||
val presenter = TimelinePresenter(
|
||||
timelineItemsFactory = timelineItemsFactory,
|
||||
room = room,
|
||||
val timeline = FakeMatrixTimeline(
|
||||
initialTimelineItems = listOf(
|
||||
MatrixTimelineItem.Virtual(0, VirtualTimelineItem.ReadMarker)
|
||||
)
|
||||
)
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
|
||||
val initialState = awaitItem()
|
||||
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
|
||||
|
||||
// Wait for timeline items to be populated
|
||||
skipItems(1)
|
||||
awaitWithLatch { latch ->
|
||||
timeline.sendReadReceiptLatch = latch
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
||||
}
|
||||
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - covers hasNewItems scenarios`() = runTest {
|
||||
val timeline = FakeMatrixTimeline()
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.hasNewItems).isFalse()
|
||||
assertThat(initialState.timelineItems.size).isEqualTo(0)
|
||||
timeline.updateTimelineItems {
|
||||
listOf(MatrixTimelineItem.Event(0, anEventTimelineItem(content = aMessageContent())))
|
||||
}
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().timelineItems.size).isEqualTo(1)
|
||||
timeline.updateTimelineItems { items ->
|
||||
items + listOf(MatrixTimelineItem.Event(1, anEventTimelineItem(content = aMessageContent())))
|
||||
}
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().timelineItems.size).isEqualTo(2)
|
||||
assertThat(awaitItem().hasNewItems).isTrue()
|
||||
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
|
||||
assertThat(awaitItem().hasNewItems).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createTimelinePresenter(
|
||||
timeline: MatrixTimeline = FakeMatrixTimeline(),
|
||||
timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory()
|
||||
): TimelinePresenter {
|
||||
return TimelinePresenter(
|
||||
timelineItemsFactory = timelineItemsFactory,
|
||||
room = FakeMatrixRoom(matrixTimeline = timeline),
|
||||
dispatchers = testCoroutineDispatchers(),
|
||||
appScope = this
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,13 +20,13 @@ import com.google.common.truth.Truth.assertThat
|
|||
import io.element.android.features.messages.fixtures.aMessageEvent
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
|
||||
import io.element.android.features.messages.impl.timeline.groups.computeGroupIdWith
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
|
||||
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
|
@ -36,7 +36,7 @@ class TimelineItemGrouperTest {
|
|||
private val sut = TimelineItemGrouper()
|
||||
|
||||
private val aGroupableItem = TimelineItem.Event(
|
||||
id = AN_EVENT_ID.value,
|
||||
id = "0",
|
||||
senderId = A_USER_ID,
|
||||
senderAvatar = anAvatarData(),
|
||||
senderDisplayName = "",
|
||||
|
|
@ -45,6 +45,7 @@ class TimelineItemGrouperTest {
|
|||
localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
|
||||
inReplyTo = null,
|
||||
debugInfo = aTimelineItemDebugInfo(),
|
||||
origin = null
|
||||
)
|
||||
private val aNonGroupableItem = aMessageEvent()
|
||||
private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today"))
|
||||
|
|
@ -75,16 +76,17 @@ class TimelineItemGrouperTest {
|
|||
fun `test groupables and ensure reordering`() {
|
||||
val result = sut.group(
|
||||
listOf(
|
||||
aGroupableItem.copy(id = AN_EVENT_ID_2.value),
|
||||
aGroupableItem,
|
||||
aGroupableItem.copy(id = "1"),
|
||||
aGroupableItem.copy(id = "0"),
|
||||
),
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
TimelineItem.GroupedEvents(
|
||||
computeGroupIdWith(aGroupableItem),
|
||||
events = listOf(
|
||||
aGroupableItem,
|
||||
aGroupableItem.copy(id = AN_EVENT_ID_2.value),
|
||||
aGroupableItem.copy("0"),
|
||||
aGroupableItem.copy(id = "1"),
|
||||
).toImmutableList()
|
||||
),
|
||||
)
|
||||
|
|
@ -127,6 +129,7 @@ class TimelineItemGrouperTest {
|
|||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
TimelineItem.GroupedEvents(
|
||||
computeGroupIdWith(aGroupableItem),
|
||||
events = listOf(
|
||||
aGroupableItem,
|
||||
aGroupableItem,
|
||||
|
|
@ -134,6 +137,7 @@ class TimelineItemGrouperTest {
|
|||
),
|
||||
aNonGroupableItem,
|
||||
TimelineItem.GroupedEvents(
|
||||
computeGroupIdWith(aGroupableItem),
|
||||
events = listOf(
|
||||
aGroupableItem,
|
||||
aGroupableItem,
|
||||
|
|
@ -143,4 +147,20 @@ class TimelineItemGrouperTest {
|
|||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when calling multiple time the method group over a growing list of groupable items, then groupId is stable`() {
|
||||
// When
|
||||
val groupableItems = mutableListOf(
|
||||
aGroupableItem.copy(id = "1"),
|
||||
aGroupableItem.copy(id = "2")
|
||||
)
|
||||
val expectedGroupId = sut.group(groupableItems).first().identifier()
|
||||
groupableItems.add(0, aGroupableItem.copy("3"))
|
||||
groupableItems.add(2, aGroupableItem.copy("4"))
|
||||
groupableItems.add(aGroupableItem.copy("5"))
|
||||
val actualGroupId = sut.group(groupableItems).first().identifier()
|
||||
// Then
|
||||
assertThat(actualGroupId).isEqualTo(expectedGroupId)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
features/messages/test/build.gradle.kts
Normal file
27
features/messages/test/build.gradle.kts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright (c) 2022 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.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.messages.test"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.features.messages.api)
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* 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.test
|
||||
|
||||
import io.element.android.features.messages.api.MessageComposerContext
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
|
||||
class MessageComposerContextFake(
|
||||
override var composerMode: MessageComposerMode = MessageComposerMode.Normal(null)
|
||||
) : MessageComposerContext
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_onboarding_sign_in_manually">"Se connecter manuellement"</string>
|
||||
<string name="screen_onboarding_sign_in_with_qr_code">"Se connecter avec un code QR"</string>
|
||||
<string name="screen_onboarding_sign_up">"Créer un compte"</string>
|
||||
<string name="screen_onboarding_subtitle">"Communiquer et collaborer en toute sécurité"</string>
|
||||
<string name="screen_onboarding_welcome_message">"Bienvenue dans l’Element le plus rapide de tous les temps. Surpuissant pour plus de vitesse et de simplicité."</string>
|
||||
<string name="screen_onboarding_welcome_subtitle">"Bienvenue dans %1$s. Affiné pour plus de rapidité et de simplicité."</string>
|
||||
<string name="screen_onboarding_welcome_title">"Soyez dans votre Element"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ dependencies {
|
|||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.features.rageshake.api)
|
||||
implementation(projects.features.analytics.api)
|
||||
implementation(projects.features.ftue.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.features.logout.api)
|
||||
implementation(projects.services.toolbox.api)
|
||||
|
|
|
|||
|
|
@ -22,12 +22,12 @@ import android.content.Context
|
|||
import coil.Coil
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.ftue.api.state.FtueState
|
||||
import io.element.android.features.preferences.impl.DefaultCacheService
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Inject
|
||||
|
|
@ -44,6 +44,7 @@ class DefaultClearCacheUseCase @Inject constructor(
|
|||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
private val defaultCacheIndexProvider: DefaultCacheService,
|
||||
private val okHttpClient: Provider<OkHttpClient>,
|
||||
private val ftueState: FtueState,
|
||||
) : ClearCacheUseCase {
|
||||
override suspend fun invoke() = withContext(coroutineDispatchers.io) {
|
||||
// Clear Matrix cache
|
||||
|
|
@ -57,6 +58,8 @@ class DefaultClearCacheUseCase @Inject constructor(
|
|||
okHttpClient.get().cache?.delete()
|
||||
// Clear app cache
|
||||
context.cacheDir.deleteRecursively()
|
||||
// Clear some settings
|
||||
ftueState.reset()
|
||||
// Ensure the app is restarted
|
||||
defaultCacheIndexProvider.onClearedCache(matrixClient.sessionId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_bug_report_attach_screenshot">"Joindre une capture d\'écran"</string>
|
||||
<string name="screen_bug_report_contact_me">"Vous pouvez me contacter si vous avez des questions complémentaires"</string>
|
||||
<string name="screen_bug_report_contact_me_title">"Me contacter"</string>
|
||||
<string name="screen_bug_report_edit_screenshot">"Modifier la capture d\'écran"</string>
|
||||
<string name="screen_bug_report_editor_description">"S\'il vous plait, veuillez décrire le bogue. Qu\'avez-vous fait ? À quoi vous attendiez-vous ? Que s\'est-il réellement passé. Veuillez ajouter le plus de détails possible."</string>
|
||||
<string name="screen_bug_report_editor_placeholder">"Décrire le bogue"</string>
|
||||
<string name="screen_bug_report_editor_supporting">"Si possible, veuillez rédiger la description en anglais."</string>
|
||||
<string name="screen_bug_report_include_crash_logs">"Envoyer des journaux d’incident"</string>
|
||||
<string name="screen_bug_report_include_logs">"Envoyer le journal pour nous aider"</string>
|
||||
<string name="screen_bug_report_include_logs">"Autoriser à inclure les journaux techniques"</string>
|
||||
<string name="screen_bug_report_include_screenshot">"Envoyer une capture d’écran"</string>
|
||||
<string name="screen_bug_report_logs_description">"Pour vérifier que les choses fonctionnent comme prévu, les journaux seront envoyés avec votre message. Ceux-ci seront privées. Pour simplement envoyer votre message, désactivez ce paramètre."</string>
|
||||
<string name="screen_bug_report_logs_description">"Pour vérifier que les choses fonctionnent comme prévu, des journaux techniques seront envoyés avec votre message. Pour l’envoyer sans ces journaux, désactivez ce paramètre."</string>
|
||||
<string name="screen_bug_report_rash_logs_alert_title">"%1$s a planté la dernière fois qu\'il a été utilisé. Souhaitez-vous partager un rapport de crash avec nous ?"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_bug_report_attach_screenshot">"Attach screenshot"</string>
|
||||
<string name="screen_bug_report_contact_me">"You may contact me if you have any follow up questions"</string>
|
||||
<string name="screen_bug_report_contact_me">"You may contact me if you have any follow up questions."</string>
|
||||
<string name="screen_bug_report_contact_me_title">"Contact me"</string>
|
||||
<string name="screen_bug_report_edit_screenshot">"Edit screenshot"</string>
|
||||
<string name="screen_bug_report_editor_description">"Please describe the bug. What did you do? What did you expect to happen? What actually happened. Please go into as much detail as you can."</string>
|
||||
|
|
|
|||
|
|
@ -5,10 +5,19 @@
|
|||
<item quantity="other">"%1$d membres"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_details_add_topic_title">"Définir un sujet"</string>
|
||||
<string name="screen_room_details_already_a_member">"Déjà membre"</string>
|
||||
<string name="screen_room_details_already_invited">"Déjà invité(e)"</string>
|
||||
<string name="screen_room_details_edit_room_title">"Modifier le salon"</string>
|
||||
<string name="screen_room_details_edition_error">"Une erreur inconnue s’est produite et les informations n’ont pas pu être modifiées."</string>
|
||||
<string name="screen_room_details_edition_error_title">"Impossible de mettre à jour le salon"</string>
|
||||
<string name="screen_room_details_encryption_enabled_subtitle">"Les messages sont sécurisés par des cadenas numériques. Seuls vous et les destinataires possédez les clés uniques pour les déverrouiller."</string>
|
||||
<string name="screen_room_details_encryption_enabled_title">"Chiffrement des messages activé"</string>
|
||||
<string name="screen_room_details_invite_people_title">"Inviter des personnes"</string>
|
||||
<string name="screen_room_details_notification_title">"Notifications"</string>
|
||||
<string name="screen_room_details_room_name_label">"Nom du salon"</string>
|
||||
<string name="screen_room_details_share_room_title">"Partager le salon"</string>
|
||||
<string name="screen_room_details_updating_room">"Mise à jour du salon…"</string>
|
||||
<string name="screen_room_member_list_pending_header_title">"En attente"</string>
|
||||
<string name="screen_dm_details_block_alert_action">"Bloquer"</string>
|
||||
<string name="screen_dm_details_block_alert_description">"Les utilisateurs bloqués ne pourront pas vous envoyer de messages et tous leurs messages seront masqués. Vous pouvez annuler cette action à tout moment."</string>
|
||||
<string name="screen_dm_details_block_user">"Bloquer l\'utilisateur"</string>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,12 @@
|
|||
<string name="screen_room_details_edition_error_title">"Nepodarilo sa aktualizovať miestnosť"</string>
|
||||
<string name="screen_room_details_encryption_enabled_subtitle">"Správy sú zabezpečené zámkami. Jedine vy a príjemcovia máte jedinečné kľúče na ich odomknutie."</string>
|
||||
<string name="screen_room_details_encryption_enabled_title">"Šifrovanie správ je zapnuté"</string>
|
||||
<string name="screen_room_details_error_loading_notification_settings">"Pri načítaní nastavení oznámení došlo k chybe."</string>
|
||||
<string name="screen_room_details_error_muting">"Nepodarilo sa stlmiť túto miestnosť, skúste to prosím znova."</string>
|
||||
<string name="screen_room_details_error_unmuting">"Nepodarilo sa zrušiť stlmenie tejto miestnosti, skúste to prosím znova."</string>
|
||||
<string name="screen_room_details_invite_people_title">"Pozvať ľudí"</string>
|
||||
<string name="screen_room_details_notification_mode_custom">"Vlastné"</string>
|
||||
<string name="screen_room_details_notification_mode_default">"Predvolené"</string>
|
||||
<string name="screen_room_details_notification_title">"Oznámenia"</string>
|
||||
<string name="screen_room_details_room_name_label">"Názov miestnosti"</string>
|
||||
<string name="screen_room_details_share_room_title">"Zdieľať miestnosť"</string>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,12 @@
|
|||
<string name="screen_room_details_edition_error_title">"Unable to update room"</string>
|
||||
<string name="screen_room_details_encryption_enabled_subtitle">"Messages are secured with locks. Only you and the recipients have the unique keys to unlock them."</string>
|
||||
<string name="screen_room_details_encryption_enabled_title">"Message encryption enabled"</string>
|
||||
<string name="screen_room_details_error_loading_notification_settings">"An error occurred when loading notification settings."</string>
|
||||
<string name="screen_room_details_error_muting">"Failed muting this room, please try again."</string>
|
||||
<string name="screen_room_details_error_unmuting">"Failed unmuting this room, please try again."</string>
|
||||
<string name="screen_room_details_invite_people_title">"Invite people"</string>
|
||||
<string name="screen_room_details_notification_mode_custom">"Custom"</string>
|
||||
<string name="screen_room_details_notification_mode_default">"Default"</string>
|
||||
<string name="screen_room_details_notification_title">"Notifications"</string>
|
||||
<string name="screen_room_details_room_name_label">"Room name"</string>
|
||||
<string name="screen_room_details_share_room_title">"Share room"</string>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@
|
|||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_roomlist_a11y_create_message">"Créer une nouvelle conversation ou un nouveau salon"</string>
|
||||
<string name="screen_roomlist_main_space_title">"Tous les chats"</string>
|
||||
<string name="session_verification_banner_message">"Il semblerait que vous utilisiez un nouvel appareil. Vérifiez que vous êtes bien autorisé à accéder à vos messages chiffrés."</string>
|
||||
<string name="session_verification_banner_title">"Accédez à l\'historique de vos messages"</string>
|
||||
<string name="session_verification_banner_message">"Il semblerait que vous utilisiez un nouvel appareil. Lancez la vérification avec un autre appareil pour accéder à vos messages chiffrés à l’avenir."</string>
|
||||
<string name="session_verification_banner_title">"Vérifier que c’est bien vous"</string>
|
||||
</resources>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue