Merge branch 'develop' of https://github.com/vector-im/element-x-android into dla/feature/connect_sdk_to_global_notifications_ui

This commit is contained in:
David Langley 2023-09-12 16:30:36 +01:00
commit c3fbac4678
686 changed files with 7212 additions and 2257 deletions

View file

@ -51,4 +51,5 @@ dependencies {
testImplementation(libs.test.mockk)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.tests.testutils)
}

View file

@ -23,11 +23,18 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class AnalyticsOptInPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - enable`() = runTest {
val analyticsService = FakeAnalyticsService(isEnabled = false)

View file

@ -23,10 +23,17 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class AnalyticsPreferencesPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state available`() = runTest {
val presenter = DefaultAnalyticsPreferencesPresenter(

View file

@ -65,6 +65,7 @@ dependencies {
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor)
}

View file

@ -23,6 +23,7 @@ import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.toImmutableList
open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListState> {
@ -36,7 +37,11 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
selectionMode = SelectionMode.Multiple,
),
aUserListState().copy(
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
searchResults = SearchBarResultState.Results(aMatrixUserList()
.mapIndexed { index, matrixUser ->
UserSearchResult(matrixUser, index % 2 == 0)
}
.toImmutableList()),
selectedUsers = aListOfSelectedUsers(),
isSearchActive = true,
selectionMode = SelectionMode.Multiple,

View file

@ -24,12 +24,18 @@ import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class AddPeoplePresenterTests {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private lateinit var presenter: AddPeoplePresenter
@Before

View file

@ -38,6 +38,7 @@ import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@ -47,6 +48,7 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@ -58,6 +60,10 @@ private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery"
@RunWith(RobolectricTestRunner::class)
class ConfigureRoomPresenterTests {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private lateinit var presenter: ConfigureRoomPresenter
private lateinit var userListDataStore: UserListDataStore
private lateinit var createRoomDataStore: CreateRoomDataStore

View file

@ -35,13 +35,19 @@ import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class CreateRoomRootPresenterTests {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private lateinit var userRepository: FakeUserRepository
private lateinit var presenter: CreateRoomRootPresenter
private lateinit var fakeUserListPresenter: FakeUserListPresenter

View file

@ -25,12 +25,18 @@ import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult
import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class DefaultUserListPresenterTests {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private val userRepository = FakeUserRepository()
@Test

View file

@ -43,6 +43,10 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api)
implementation(projects.services.analytics.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.permissions.noop)
implementation(projects.services.toolbox.api)
implementation(projects.services.toolbox.test)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
@ -51,6 +55,9 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.libraries.permissions.impl)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor)
}

View file

@ -35,6 +35,7 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.impl.migration.MigrationScreenNode
import io.element.android.features.ftue.impl.notifications.NotificationsOptInNode
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
@ -79,6 +80,9 @@ class FtueFlowNode @AssistedInject constructor(
@Parcelize
data object WelcomeScreen : NavTarget
@Parcelize
data object NotificationsOptIn : NavTarget
@Parcelize
data object AnalyticsOptIn : NavTarget
}
@ -124,6 +128,14 @@ class FtueFlowNode @AssistedInject constructor(
}
createNode<WelcomeNode>(buildContext, listOf(callback))
}
NavTarget.NotificationsOptIn -> {
val callback = object : NotificationsOptInNode.Callback {
override fun onNotificationsOptInFinished() {
lifecycleScope.launch { moveToNextStep() }
}
}
createNode<NotificationsOptInNode>(buildContext, listOf(callback))
}
NavTarget.AnalyticsOptIn -> {
analyticsEntryPoint.createNode(this, buildContext)
}
@ -138,6 +150,9 @@ class FtueFlowNode @AssistedInject constructor(
FtueStep.WelcomeScreen -> {
backstack.newRoot(NavTarget.WelcomeScreen)
}
FtueStep.NotificationsOptIn -> {
backstack.newRoot(NavTarget.NotificationsOptIn)
}
FtueStep.AnalyticsOptIn -> {
backstack.replace(NavTarget.AnalyticsOptIn)
}

View file

@ -0,0 +1,22 @@
/*
* 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.notifications
sealed interface NotificationsOptInEvents {
data object ContinueClicked : NotificationsOptInEvents
data object NotNowClicked : NotificationsOptInEvents
}

View file

@ -0,0 +1,57 @@
/*
* 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.notifications
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.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
class NotificationsOptInNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenterFactory: NotificationsOptInPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
interface Callback: NodeInputs {
fun onNotificationsOptInFinished()
}
private val callback = inputs<Callback>()
private val presenter: NotificationsOptInPresenter by lazy {
presenterFactory.create(callback)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
NotificationsOptInView(
state = state,
onBack = { callback.onNotificationsOptInFinished() },
modifier = modifier
)
}
}

View file

@ -0,0 +1,97 @@
/*
* 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.notifications
import android.Manifest
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class NotificationsOptInPresenter @AssistedInject constructor(
private val permissionsPresenterFactory: PermissionsPresenter.Factory,
@Assisted private val callback: NotificationsOptInNode.Callback,
private val appCoroutineScope: CoroutineScope,
private val permissionStateProvider: PermissionStateProvider,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
) : Presenter<NotificationsOptInState> {
@AssistedFactory
interface Factory {
fun create(callback: NotificationsOptInNode.Callback): NotificationsOptInPresenter
}
private val postNotificationPermissionsPresenter by lazy {
// Ask for POST_NOTIFICATION PERMISSION on Android 13+
if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
permissionsPresenterFactory.create(Manifest.permission.POST_NOTIFICATIONS)
} else {
NoopPermissionsPresenter()
}
}
@Composable
override fun present(): NotificationsOptInState {
val notificationsPermissionsState = postNotificationPermissionsPresenter.present()
fun handleEvents(event: NotificationsOptInEvents) {
when (event) {
NotificationsOptInEvents.ContinueClicked -> {
if (notificationsPermissionsState.permissionGranted) {
callback.onNotificationsOptInFinished()
} else {
notificationsPermissionsState.eventSink(PermissionsEvents.OpenSystemDialog)
}
}
NotificationsOptInEvents.NotNowClicked -> {
if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
appCoroutineScope.setPermissionDenied()
}
callback.onNotificationsOptInFinished()
}
}
}
LaunchedEffect(notificationsPermissionsState) {
if (notificationsPermissionsState.permissionGranted
|| notificationsPermissionsState.permissionAlreadyDenied) {
callback.onNotificationsOptInFinished()
}
}
return NotificationsOptInState(
notificationsPermissionState = notificationsPermissionsState,
eventSink = ::handleEvents
)
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun CoroutineScope.setPermissionDenied() = launch {
permissionStateProvider.setPermissionDenied(Manifest.permission.POST_NOTIFICATIONS, true)
}
}

View file

@ -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.ftue.impl.notifications
import io.element.android.libraries.permissions.api.PermissionsState
data class NotificationsOptInState(
val notificationsPermissionState: PermissionsState,
val eventSink: (NotificationsOptInEvents) -> Unit
)

View file

@ -0,0 +1,33 @@
/*
* 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.notifications
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.permissions.api.aPermissionsState
open class NotificationsOptInStateProvider : PreviewParameterProvider<NotificationsOptInState> {
override val values: Sequence<NotificationsOptInState>
get() = sequenceOf(
aNotificationsOptInState(),
// Add other states here
)
}
fun aNotificationsOptInState() = NotificationsOptInState(
notificationsPermissionState = aPermissionsState(),
eventSink = {}
)

View file

@ -0,0 +1,197 @@
/*
* 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.notifications
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.ftue.impl.R
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.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
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.Surface
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun NotificationsOptInView(
state: NotificationsOptInState,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(onBack = onBack)
HeaderFooterPage(
modifier = modifier
.systemBarsPadding()
.fillMaxSize(),
header = { NotificationsOptInHeader(modifier = Modifier.padding(top = 60.dp, bottom = 12.dp)) },
footer = { NotificationsOptInFooter(state) },
) {
NotificationsOptInContent(modifier = Modifier.fillMaxWidth())
}
}
@Composable
private fun NotificationsOptInHeader(
modifier: Modifier = Modifier,
) {
IconTitleSubtitleMolecule(
modifier = modifier,
title = stringResource(R.string.screen_notification_optin_title),
subTitle = stringResource(R.string.screen_notification_optin_subtitle),
iconImageVector = Icons.Default.Notifications,
)
}
@Composable
private fun NotificationsOptInFooter(state: NotificationsOptInState) {
ButtonColumnMolecule {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_ok),
onClick = {
state.eventSink(NotificationsOptInEvents.ContinueClicked)
}
)
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_not_now),
onClick = {
state.eventSink(NotificationsOptInEvents.NotNowClicked)
}
)
}
}
@Composable
private fun NotificationsOptInContent(
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
verticalArrangement = Arrangement.spacedBy(
16.dp,
alignment = Alignment.CenterVertically
)
) {
NotificationRow(
avatarLetter = "M",
avatarColorsId = "5",
firstRowPercent = 1f,
secondRowPercent = 0.4f
)
NotificationRow(
avatarLetter = "A",
avatarColorsId = "1",
firstRowPercent = 1f,
secondRowPercent = 1f
)
NotificationRow(
avatarLetter = "T",
avatarColorsId = "4",
firstRowPercent = 0.65f,
secondRowPercent = 0f
)
}
}
}
@Composable
private fun NotificationRow(
avatarLetter: String,
avatarColorsId: String,
firstRowPercent: Float,
secondRowPercent: Float,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier,
color = ElementTheme.colors.bgCanvasDisabled,
shape = RoundedCornerShape(14.dp),
shadowElevation = 2.dp,
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(
avatarData = AvatarData(id = avatarColorsId, name = avatarLetter, size = AvatarSize.NotificationsOptIn),
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Box(
modifier = Modifier
.clip(CircleShape)
.fillMaxWidth(firstRowPercent)
.height(10.dp)
.background(ElementTheme.colors.borderInteractiveSecondary)
)
if (secondRowPercent > 0f) {
Box(
modifier = Modifier
.clip(CircleShape)
.fillMaxWidth(secondRowPercent)
.height(10.dp)
.background(ElementTheme.colors.borderInteractiveSecondary)
)
}
}
}
}
}
@DayNightPreviews
@Composable
internal fun NotificationsOptInViewPreview(
@PreviewParameter(NotificationsOptInStateProvider::class) state: NotificationsOptInState
) {
ElementPreview {
NotificationsOptInView(
onBack = {},
state = state,
)
}
}

View file

@ -16,6 +16,8 @@
package io.element.android.features.ftue.impl.state
import android.Manifest
import android.os.Build
import androidx.annotation.VisibleForTesting
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueState
@ -23,7 +25,9 @@ import io.element.android.features.ftue.impl.migration.MigrationScreenStore
import io.element.android.features.ftue.impl.welcome.state.WelcomeScreenState
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
@ -34,10 +38,12 @@ import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultFtueState @Inject constructor(
private val sdkVersionProvider: BuildVersionSdkIntProvider,
private val coroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
private val welcomeScreenState: WelcomeScreenState,
private val migrationScreenStore: MigrationScreenStore,
private val permissionStateProvider: PermissionStateProvider,
private val matrixClient: MatrixClient,
) : FtueState {
@ -47,6 +53,9 @@ class DefaultFtueState @Inject constructor(
welcomeScreenState.reset()
analyticsService.reset()
migrationScreenStore.reset()
if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
permissionStateProvider.resetPermission(Manifest.permission.POST_NOTIFICATIONS)
}
}
init {
@ -63,7 +72,10 @@ class DefaultFtueState @Inject constructor(
FtueStep.MigrationScreen -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep(
FtueStep.WelcomeScreen
)
FtueStep.WelcomeScreen -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep(
FtueStep.WelcomeScreen -> if (shouldAskNotificationPermissions()) FtueStep.NotificationsOptIn else getNextStep(
FtueStep.NotificationsOptIn
)
FtueStep.NotificationsOptIn -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep(
FtueStep.AnalyticsOptIn
)
FtueStep.AnalyticsOptIn -> null
@ -73,6 +85,7 @@ class DefaultFtueState @Inject constructor(
return listOf(
shouldDisplayMigrationScreen(),
shouldDisplayWelcomeScreen(),
shouldAskNotificationPermissions(),
needsAnalyticsOptIn()
).any { it }
}
@ -90,6 +103,15 @@ class DefaultFtueState @Inject constructor(
return welcomeScreenState.isWelcomeScreenNeeded()
}
private fun shouldAskNotificationPermissions(): Boolean {
return if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
val permission = Manifest.permission.POST_NOTIFICATIONS
val isPermissionDenied = runBlocking { permissionStateProvider.isPermissionDenied(permission).first() }
val isPermissionGranted = permissionStateProvider.isPermissionGranted(permission)
!isPermissionGranted && !isPermissionDenied
} else false
}
fun setWelcomeScreenShown() {
welcomeScreenState.setWelcomeScreenShown()
updateState()
@ -104,5 +126,6 @@ class DefaultFtueState @Inject constructor(
sealed interface FtueStep {
data object MigrationScreen : FtueStep
data object WelcomeScreen : FtueStep
data object NotificationsOptIn : FtueStep
data object AnalyticsOptIn : FtueStep
}

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Toto je jednorázový proces, děkujeme za čekání."</string>
<string name="screen_migration_message">"Jedná se o jednorázový proces, prosíme o strpení."</string>
<string name="screen_migration_title">"Nastavení vašeho účtu"</string>
<string name="screen_welcome_bullet_1">"Hovory, hlasování, vyhledávání a další budou přidány koncem tohoto roku."</string>
<string name="screen_welcome_bullet_2">"Historie zpráv šifrovaných místností nebude v této aktualizaci k dispozici."</string>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Acesta este un proces care se desfășoară o singură dată, vă mulțumim pentru așteptare."</string>
<string name="screen_migration_title">"Contul dumneavoastră se configurează"</string>
<string name="screen_welcome_bullet_1">"Apelurile, sondajele, căutare și multe altele vor fi adăugate în cursul acestui an."</string>
<string name="screen_welcome_bullet_2">"Istoricul mesajelor pentru camerele criptate nu va fi disponibil în această actualizare."</string>
<string name="screen_welcome_bullet_3">"Ne-ar plăcea să auzim de la dumneavoastră, spuneți-ne ce părere aveți prin intermediul paginii de setări."</string>
<string name="screen_welcome_button">"Să începem!"</string>
<string name="screen_welcome_subtitle">"Iată ce trebuie să știți:"</string>
<string name="screen_welcome_title">"Bun venit la%1$s!"</string>
</resources>

View file

@ -2,6 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"This is a one time process, thanks for waiting."</string>
<string name="screen_migration_title">"Setting up your account."</string>
<string name="screen_notification_optin_subtitle">"You can change your settings later."</string>
<string name="screen_notification_optin_title">"Allow notifications and never miss a message"</string>
<string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string>
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms wont be available in this update."</string>
<string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string>

View file

@ -16,6 +16,7 @@
package io.element.android.features.ftue.impl
import android.os.Build
import com.google.common.truth.Truth.assertThat
import io.element.android.features.ftue.impl.migration.InMemoryMigrationScreenStore
import io.element.android.features.ftue.impl.migration.MigrationScreenStore
@ -25,8 +26,10 @@ import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
@ -51,13 +54,21 @@ class DefaultFtueStateTests {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope, welcomeState, analyticsService, migrationScreenStore)
val state = createState(
coroutineScope = coroutineScope,
welcomeState = welcomeState,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider
)
welcomeState.setWelcomeScreenShown()
analyticsService.setDidAskUserConsent()
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
permissionStateProvider.setPermissionGranted()
state.updateState()
assertThat(state.shouldDisplayFlow.value).isFalse()
@ -71,9 +82,16 @@ class DefaultFtueStateTests {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope, welcomeState, analyticsService, migrationScreenStore)
val state = createState(
coroutineScope = coroutineScope,
welcomeState = welcomeState,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider
)
val steps = mutableListOf<FtueStep?>()
// First step, migration screen
@ -84,7 +102,11 @@ class DefaultFtueStateTests {
steps.add(state.getNextStep(steps.lastOrNull()))
welcomeState.setWelcomeScreenShown()
// Third step, analytics opt in
// Third step, notifications opt in
steps.add(state.getNextStep(steps.lastOrNull()))
permissionStateProvider.setPermissionGranted()
// Fourth step, analytics opt in
steps.add(state.getNextStep(steps.lastOrNull()))
analyticsService.setDidAskUserConsent()
@ -94,6 +116,7 @@ class DefaultFtueStateTests {
assertThat(steps).containsExactly(
FtueStep.MigrationScreen,
FtueStep.WelcomeScreen,
FtueStep.NotificationsOptIn,
FtueStep.AnalyticsOptIn,
null, // Final state
)
@ -107,11 +130,40 @@ class DefaultFtueStateTests {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val state = createState(
coroutineScope = coroutineScope,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider,
)
// Skip first 3 steps
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
state.setWelcomeScreenShown()
permissionStateProvider.setPermissionGranted()
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
analyticsService.setDidAskUserConsent()
assertThat(state.getNextStep(FtueStep.WelcomeScreen)).isNull()
// Cleanup
coroutineScope.cancel()
}
@Test
fun `if version is older than 13 we don't display the notification opt in screen`() = runTest {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val state = createState(
sdkIntVersion = Build.VERSION_CODES.M,
coroutineScope = coroutineScope,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
)
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
@ -132,12 +184,16 @@ class DefaultFtueStateTests {
welcomeState: FakeWelcomeState = FakeWelcomeState(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
migrationScreenStore: MigrationScreenStore = InMemoryMigrationScreenStore(),
permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
matrixClient: MatrixClient = FakeMatrixClient(),
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU, // First version where notification permission is required
) = DefaultFtueState(
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
coroutineScope = coroutineScope,
analyticsService = analyticsService,
welcomeScreenState = welcomeState,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider,
matrixClient = matrixClient,
)
}

View file

@ -25,10 +25,17 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class MigrationScreenPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial`() = runTest {
val presenter = createPresenter()

View file

@ -0,0 +1,148 @@
/*
* 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.notifications
import android.os.Build
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class NotificationsOptInPresenterTests {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private var isFinished = false
@Test
fun `initial state`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.notificationsPermissionState.showDialog).isFalse()
}
}
@Test
fun `show dialog on continue clicked`() = runTest {
val permissionPresenter = FakePermissionsPresenter()
val presenter = createPresenter(permissionPresenter)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(NotificationsOptInEvents.ContinueClicked)
Truth.assertThat(awaitItem().notificationsPermissionState.showDialog).isTrue()
}
}
@Test
fun `finish flow on continue clicked with permission already granted`() = runTest {
val permissionPresenter = FakePermissionsPresenter().apply {
setPermissionGranted()
}
val presenter = createPresenter(permissionPresenter)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(NotificationsOptInEvents.ContinueClicked)
Truth.assertThat(isFinished).isTrue()
}
}
@Test
fun `finish flow on not now clicked`() = runTest {
val permissionPresenter = FakePermissionsPresenter()
val presenter = createPresenter(
permissionsPresenter = permissionPresenter,
sdkIntVersion = Build.VERSION_CODES.M
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(NotificationsOptInEvents.NotNowClicked)
Truth.assertThat(isFinished).isTrue()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `set permission denied on not now clicked in API 33`() = runTest(StandardTestDispatcher()) {
val permissionPresenter = FakePermissionsPresenter()
val permissionStateProvider = FakePermissionStateProvider()
val presenter = createPresenter(
permissionsPresenter = permissionPresenter,
permissionStateProvider = permissionStateProvider,
sdkIntVersion = Build.VERSION_CODES.TIRAMISU
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(NotificationsOptInEvents.NotNowClicked)
// Allow background coroutines to run
runCurrent()
val isPermissionDenied = runBlocking {
permissionStateProvider.isPermissionDenied("notifications").first()
}
Truth.assertThat(isPermissionDenied).isTrue()
}
}
private fun TestScope.createPresenter(
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
permissionStateProvider: PermissionStateProvider = FakePermissionStateProvider(),
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
) = NotificationsOptInPresenter(
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permission: String): PermissionsPresenter {
return permissionsPresenter
}
},
callback = object : NotificationsOptInNode.Callback {
override fun onNotificationsOptInFinished() {
isFinished = true
}
},
appCoroutineScope = this,
permissionStateProvider = permissionStateProvider,
buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
)
}

View file

@ -54,6 +54,7 @@ dependencies {
testImplementation(projects.libraries.push.test)
testImplementation(projects.features.invitelist.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor)
}

View file

@ -44,10 +44,15 @@ import io.element.android.libraries.push.api.notifications.NotificationDrawerMan
import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class InviteListPresenterTests {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - starts empty, adds invites when received`() = runTest {

View file

@ -29,14 +29,20 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MembershipCha
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class LeaveRoomPresenterImplTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state hides all dialogs`() = runTest {
val presenter = createPresenter()

View file

@ -57,4 +57,5 @@ dependencies {
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.tests.testutils)
}

View file

@ -26,10 +26,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@ -119,9 +119,8 @@ class SendLocationPresenter @Inject constructor(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
isLocation = true,
isReply = messageComposerContext.composerMode.isReply,
locationType = Composer.LocationType.PinDrop,
messageType = Composer.MessageType.LocationPin,
)
)
}
@ -138,9 +137,8 @@ class SendLocationPresenter @Inject constructor(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
isLocation = true,
isReply = messageComposerContext.composerMode.isReply,
locationType = Composer.LocationType.MyLocation,
messageType = Composer.MessageType.LocationUser,
)
)
}

View file

@ -34,12 +34,18 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.SendLocationInvocation
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class SendLocationPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private val permissionsPresenterFake = PermissionsPresenterFake()
private val fakeMatrixRoom = FakeMatrixRoom()
private val fakeAnalyticsService = FakeAnalyticsService()
@ -302,9 +308,8 @@ class SendLocationPresenterTest {
Composer(
inThread = false,
isEditing = false,
isLocation = true,
isReply = false,
locationType = Composer.LocationType.MyLocation,
messageType = Composer.MessageType.LocationUser,
)
)
}
@ -359,9 +364,8 @@ class SendLocationPresenterTest {
Composer(
inThread = false,
isEditing = false,
isLocation = true,
isReply = false,
locationType = Composer.LocationType.PinDrop,
messageType = Composer.MessageType.LocationPin,
)
)
}
@ -406,9 +410,8 @@ class SendLocationPresenterTest {
Composer(
inThread = false,
isEditing = true,
isLocation = true,
isReply = false,
locationType = Composer.LocationType.PinDrop,
messageType = Composer.MessageType.LocationPin,
)
)
}

View file

@ -27,12 +27,18 @@ import io.element.android.features.location.impl.common.permissions.PermissionsP
import io.element.android.features.location.impl.common.permissions.PermissionsPresenterFake
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ShowLocationPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private val permissionsPresenterFake = PermissionsPresenterFake()
private val fakeLocationActions = FakeLocationActions()
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")

View file

@ -5,10 +5,11 @@
<string name="screen_account_provider_form_notice">"Introduceţi un termen de căutare sau o adresă de domeniu."</string>
<string name="screen_account_provider_form_subtitle">"Căutați o companie, o comunitate sau un server privat."</string>
<string name="screen_account_provider_form_title">"Găsiți un furnizor de cont"</string>
<string name="screen_account_provider_signin_subtitle">"Aici vor trăi conversațiile - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."</string>
<string name="screen_account_provider_signin_subtitle">"Aici vor trăi conversațiile dumneavoastră - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."</string>
<string name="screen_account_provider_signin_title">"Sunteți pe cale să vă conectați la %s"</string>
<string name="screen_account_provider_signup_subtitle">"Aici vor trăi conversațiile - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."</string>
<string name="screen_account_provider_signup_subtitle">"Aici vor trăi conversațiile dumneavoastră - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."</string>
<string name="screen_account_provider_signup_title">"Sunteți pe cale să creați un cont pe %s"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org este un server mare și gratuit din rețeaua publică Matrix pentru comunicații sigure și descentralizate, administrat de Fundația Matrix.org."</string>
<string name="screen_change_account_provider_other">"Altul"</string>
<string name="screen_change_account_provider_subtitle">"Utilizați un alt furnizor de cont, cum ar fi propriul server privat sau un cont de serviciu."</string>
<string name="screen_change_account_provider_title">"Schimbați furnizorul contului"</string>

View file

@ -9,6 +9,7 @@
<string name="screen_account_provider_signin_title">"Вы собираетесь войти в %s"</string>
<string name="screen_account_provider_signup_subtitle">"Здесь будут храниться ваши разговоры - точно так же, как вы используете почтового провайдера для хранения своих писем."</string>
<string name="screen_account_provider_signup_title">"Вы собираетесь создать учетную запись на %s"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org — это большой бесплатный сервер в общедоступной сети Matrix для безопасной децентрализованной связи, управляемый Matrix.org Foundation."</string>
<string name="screen_change_account_provider_other">"Другое"</string>
<string name="screen_change_account_provider_subtitle">"Используйте другого поставщика учетных записей, например, собственный частный сервер или рабочую учетную запись."</string>
<string name="screen_change_account_provider_title">"Сменить поставщика учетной записи"</string>

View file

@ -26,10 +26,17 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ChangeServerPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = ChangeServerPresenter(

View file

@ -27,11 +27,18 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class OidcPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = OidcPresenter(

View file

@ -24,10 +24,17 @@ import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ChangeAccountProviderPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val changeServerPresenter = ChangeServerPresenter(

View file

@ -31,11 +31,18 @@ import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.waitForPredicate
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ConfirmAccountProviderPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial test`() = runTest {
val presenter = createConfirmAccountProviderPresenter()

View file

@ -31,10 +31,17 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class LoginPasswordPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val authenticationService = FakeAuthenticationService()

View file

@ -30,11 +30,18 @@ import io.element.android.features.login.impl.resolver.network.WellKnownSlidingS
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class SearchAccountProviderPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val fakeWellknownRequest = FakeWellknownRequest()

View file

@ -30,10 +30,17 @@ import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class WaitListPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val authenticationService = FakeAuthenticationService().apply {

View file

@ -48,4 +48,5 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
}

View file

@ -25,10 +25,17 @@ import io.element.android.features.logout.api.LogoutPreferenceState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class LogoutPreferencePresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = DefaultLogoutPreferencePresenter(

View file

@ -25,5 +25,5 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
api(projects.libraries.textcomposer)
api(projects.libraries.textcomposer.impl)
}

View file

@ -41,7 +41,7 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.textcomposer)
implementation(projects.libraries.textcomposer.impl)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.eventformatter.api)
@ -61,7 +61,7 @@ dependencies {
implementation(libs.accompanist.systemui)
implementation(libs.vanniktech.blurhash)
implementation(libs.telephoto.zoomableimage)
implementation(libs.vanniktech.emoji)
implementation(libs.matrix.emojibase.bindings)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
@ -76,6 +76,7 @@ dependencies {
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.textcomposer.test)
testImplementation(libs.test.mockk)
ksp(libs.showkase.processor)

View file

@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.PollEnd
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@ -59,7 +60,6 @@ import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -75,6 +75,7 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.room.canRedactAsState
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -93,6 +94,7 @@ class MessagesPresenter @AssistedInject constructor(
private val messageSummaryFormatter: MessageSummaryFormatter,
private val dispatchers: CoroutineDispatchers,
private val clipboardHelper: ClipboardHelper,
private val analyticsService: AnalyticsService,
@Assisted private val navigator: MessagesNavigator,
) : Presenter<MessagesState> {
@ -176,7 +178,7 @@ class MessagesPresenter @AssistedInject constructor(
snackbarMessage = snackbarMessage,
showReinvitePrompt = showReinvitePrompt,
inviteProgress = inviteProgress.value,
eventSink = ::handleEvents
eventSink = { handleEvents(it) }
)
}
@ -202,6 +204,7 @@ class MessagesPresenter @AssistedInject constructor(
TimelineItemAction.Developer -> handleShowDebugInfoAction(targetEvent)
TimelineItemAction.Forward -> handleForwardAction(targetEvent)
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent)
}
}
@ -214,7 +217,8 @@ class MessagesPresenter @AssistedInject constructor(
}
private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState<Async<Unit>>) = launch(dispatchers.io) {
suspend {
inviteProgress.value = Async.Loading()
runCatching {
room.updateMembers()
val memberList = when (val memberState = room.membersStateFlow.value) {
@ -227,7 +231,14 @@ class MessagesPresenter @AssistedInject constructor(
room.inviteUserById(member.userId).onFailure { t ->
Timber.e(t, "Failed to reinvite DM partner")
}.getOrThrow()
}.runCatchingUpdatingState(inviteProgress)
}.fold(
onSuccess = {
inviteProgress.value = Async.Success(Unit)
},
onFailure = {
inviteProgress.value = Async.Failure(it)
}
)
}
private suspend fun handleActionRedact(event: TimelineItem.Event) {
@ -242,7 +253,9 @@ class MessagesPresenter @AssistedInject constructor(
private fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {
val composerMode = MessageComposerMode.Edit(
targetEvent.eventId,
(targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty(),
(targetEvent.content as? TimelineItemTextBasedContent)?.let {
it.htmlBody ?: it.body
}.orEmpty(),
targetEvent.transactionId,
)
composerState.eventSink(
@ -310,6 +323,13 @@ class MessagesPresenter @AssistedInject constructor(
navigator.onReportContentClicked(event.eventId, event.senderId)
}
private suspend fun handleEndPollAction(event: TimelineItem.Event) {
event.eventId?.let {
room.endPoll(it, "The poll with event id: $it has ended.")
analyticsService.capture(PollEnd())
}
}
private suspend fun handleCopyContents(event: TimelineItem.Event) {
val content = when (event.content) {
is TimelineItemTextBasedContent -> event.content.body

View file

@ -30,6 +30,7 @@ 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
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.persistentSetOf
open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
@ -54,7 +55,9 @@ fun aMessagesState() = MessagesState(
userHasPermissionToSendMessage = true,
userHasPermissionToRedact = false,
composerState = aMessageComposerState().copy(
text = "Hello",
richTextEditorState = RichTextEditorState("Hello", fake = true).apply {
requestFocus()
},
isFullScreen = false,
mode = MessageComposerMode.Normal("Hello"),
),
@ -67,7 +70,7 @@ fun aMessagesState() = MessagesState(
),
actionListState = anActionListState(),
customReactionState = CustomReactionState(
selectedEventId = null,
target = CustomReactionState.Target.None,
eventSink = {},
selectedEmoji = persistentSetOf(),
),

View file

@ -123,7 +123,13 @@ fun MessagesView(
fun onMessageLongClicked(event: TimelineItem.Event) {
Timber.v("OnMessageLongClicked= ${event.id}")
localView.hideKeyboard()
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event, state.userHasPermissionToRedact))
state.actionListState.eventSink(
ActionListEvents.ComputeForMessage(
event = event,
canRedact = state.userHasPermissionToRedact,
canSendMessage = state.userHasPermissionToSendMessage,
)
)
}
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
@ -141,7 +147,7 @@ fun MessagesView(
}
fun onMoreReactionsClicked(event: TimelineItem.Event) {
state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event))
state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
}
Scaffold(
@ -194,18 +200,17 @@ fun MessagesView(
state = state.actionListState,
onActionSelected = ::onActionSelected,
onCustomReactionClicked = { event ->
state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event))
if (event.eventId == null) return@ActionListView
state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
},
onEmojiReactionClicked = ::onEmojiReactionClicked,
)
CustomReactionBottomSheet(
state = state.customReactionState,
onEmojiSelected = { emoji ->
state.customReactionState.selectedEventId?.let { eventId ->
state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId))
state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(null))
}
onEmojiSelected = { eventId, emoji ->
state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId))
state.customReactionState.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
}
)

View file

@ -20,5 +20,9 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface ActionListEvents {
data object Clear : ActionListEvents
data class ComputeForMessage(val event: TimelineItem.Event, val canRedact: Boolean) : ActionListEvents
data class ComputeForMessage(
val event: TimelineItem.Event,
val canRedact: Boolean,
val canSendMessage: Boolean,
) : ActionListEvents
}

View file

@ -62,6 +62,7 @@ class ActionListPresenter @Inject constructor(
is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(
timelineItem = event.event,
userCanRedact = event.canRedact,
userCanSendMessage = event.canSendMessage,
target = target,
)
}
@ -70,13 +71,14 @@ class ActionListPresenter @Inject constructor(
return ActionListState(
target = target.value,
displayEmojiReactions = displayEmojiReactions,
eventSink = ::handleEvents
eventSink = { handleEvents(it) }
)
}
private fun CoroutineScope.computeForMessage(
timelineItem: TimelineItem.Event,
userCanRedact: Boolean,
userCanSendMessage: Boolean,
target: MutableState<ActionListState.Target>
) = launch {
target.value = ActionListState.Target.Loading(timelineItem)
@ -99,6 +101,17 @@ class ActionListPresenter @Inject constructor(
}
is TimelineItemPollContent -> {
buildList {
val isMineOrCanRedact = timelineItem.isMine || userCanRedact
// TODO Poll: Reply to poll. Ensure to update `fun TimelineItemEventContent.canBeReplied()`
// when touching this
// if (timelineItem.isRemote) {
// // Can only reply or forward messages already uploaded to the server
// add(TimelineItemAction.Reply)
// }
if (!timelineItem.content.isEnded && timelineItem.isRemote && isMineOrCanRedact) {
add(TimelineItemAction.EndPoll)
}
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy)
}
@ -108,7 +121,7 @@ class ActionListPresenter @Inject constructor(
if (!timelineItem.isMine) {
add(TimelineItemAction.ReportContent)
}
if (timelineItem.isMine || userCanRedact) {
if (isMineOrCanRedact) {
add(TimelineItemAction.Redact)
}
}
@ -116,7 +129,9 @@ class ActionListPresenter @Inject constructor(
else -> buildList<TimelineItemAction> {
if (timelineItem.isRemote) {
// Can only reply or forward messages already uploaded to the server
add(TimelineItemAction.Reply)
if (userCanSendMessage) {
add(TimelineItemAction.Reply)
}
add(TimelineItemAction.Forward)
}
if (timelineItem.isMine && timelineItem.isTextMessage) {

View file

@ -23,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -83,6 +84,15 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
),
displayEmojiReactions = false,
),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemPollContent()).copy(
reactionsState = reactionsState
),
actions = aTimelineItemPollActionList(),
),
displayEmojiReactions = false,
),
)
}
}
@ -104,3 +114,13 @@ fun aTimelineItemActionList(): ImmutableList<TimelineItemAction> {
TimelineItemAction.Developer,
)
}
fun aTimelineItemPollActionList(): ImmutableList<TimelineItemAction> {
return persistentListOf(
TimelineItemAction.EndPoll,
TimelineItemAction.Reply,
TimelineItemAction.Copy,
TimelineItemAction.Developer,
TimelineItemAction.ReportContent,
TimelineItemAction.Redact,
)
}

View file

@ -35,4 +35,5 @@ sealed class TimelineItemAction(
data object Edit : TimelineItemAction(CommonStrings.action_edit, VectorIcons.Edit)
data object Developer : TimelineItemAction(CommonStrings.action_view_source, VectorIcons.DeveloperMode)
data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, VectorIcons.ReportContent, destructive = true)
data object EndPoll : TimelineItemAction(CommonStrings.action_end_poll, VectorIcons.PollEnd)
}

View file

@ -26,6 +26,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.Collections
import androidx.compose.material.icons.filled.FormatColorText
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.Videocam
@ -145,6 +146,11 @@ internal fun AttachmentSourcePickerMenu(
text = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
)
}
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.ToggleTextFormatting(enabled = true)) },
icon = { Icon(Icons.Default.FormatColorText, null) },
text = { Text(stringResource(R.string.screen_room_attachment_text_formatting)) },
)
}
}

View file

@ -17,16 +17,15 @@
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable
import io.element.android.libraries.textcomposer.Message
import io.element.android.libraries.textcomposer.MessageComposerMode
@Immutable
sealed interface MessageComposerEvents {
data object ToggleFullScreenState : MessageComposerEvents
data class FocusChanged(val hasFocus: Boolean) : MessageComposerEvents
data class SendMessage(val message: String) : MessageComposerEvents
data class SendMessage(val message: Message) : MessageComposerEvents
data object CloseSpecialMode : MessageComposerEvents
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
data class UpdateText(val text: String) : MessageComposerEvents
data object AddAttachment : MessageComposerEvents
data object DismissAttachmentMenu : MessageComposerEvents
sealed interface PickAttachmentSource : MessageComposerEvents {
@ -37,5 +36,7 @@ sealed interface MessageComposerEvents {
data object Location : PickAttachmentSource
data object Poll : PickAttachmentSource
}
data class ToggleTextFormatting(val enabled: Boolean) : MessageComposerEvents
data object CancelSendAttachment : MessageComposerEvents
data class Error(val error: Throwable) : MessageComposerEvents
}

View file

@ -44,8 +44,10 @@ import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.textcomposer.Message
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@ -67,6 +69,7 @@ class MessageComposerPresenter @Inject constructor(
private val snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService,
private val messageComposerContext: MessageComposerContextImpl,
private val richTextEditorStateFactory: RichTextEditorStateFactory,
) : Presenter<MessageComposerState> {
@SuppressLint("UnsafeOptInUsageError")
@ -103,19 +106,16 @@ class MessageComposerPresenter @Inject constructor(
val isFullScreen = rememberSaveable {
mutableStateOf(false)
}
val hasFocus = remember {
mutableStateOf(false)
}
val text: MutableState<String> = rememberSaveable {
mutableStateOf("")
}
val richTextEditorState = richTextEditorStateFactory.create()
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
var showTextFormatting: Boolean by remember { mutableStateOf(false) }
LaunchedEffect(messageComposerContext.composerMode) {
when (val modeValue = messageComposerContext.composerMode) {
is MessageComposerMode.Edit -> text.value = modeValue.defaultContent
is MessageComposerMode.Edit ->
richTextEditorState.setHtml(modeValue.defaultContent)
else -> Unit
}
}
@ -136,18 +136,15 @@ class MessageComposerPresenter @Inject constructor(
when (event) {
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
is MessageComposerEvents.FocusChanged -> hasFocus.value = event.hasFocus
is MessageComposerEvents.UpdateText -> text.value = event.text
MessageComposerEvents.CloseSpecialMode -> {
text.value = ""
richTextEditorState.setHtml("")
messageComposerContext.composerMode = MessageComposerMode.Normal("")
}
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(
text = event.message,
message = event.message,
updateComposerMode = { messageComposerContext.composerMode = it },
textState = text
richTextEditorState = richTextEditorState,
)
is MessageComposerEvents.SetMode -> {
messageComposerContext.composerMode = event.composerMode
@ -156,7 +153,7 @@ class MessageComposerPresenter @Inject constructor(
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
isReply = messageComposerContext.composerMode.isReply,
isLocation = false,
messageType = Composer.MessageType.Text,
)
)
}
@ -194,43 +191,51 @@ class MessageComposerPresenter @Inject constructor(
ongoingSendAttachmentJob.value == null
}
}
is MessageComposerEvents.ToggleTextFormatting -> {
showAttachmentSourcePicker = false
showTextFormatting = event.enabled
}
is MessageComposerEvents.Error -> {
analyticsService.trackError(event.error)
}
}
}
return MessageComposerState(
text = text.value,
richTextEditorState = richTextEditorState,
isFullScreen = isFullScreen.value,
hasFocus = hasFocus.value,
mode = messageComposerContext.composerMode,
showAttachmentSourcePicker = showAttachmentSourcePicker,
showTextFormatting = showTextFormatting,
canShareLocation = canShareLocation.value,
canCreatePoll = canCreatePoll.value,
attachmentsState = attachmentsState.value,
eventSink = ::handleEvents
eventSink = { handleEvents(it) }
)
}
private fun CoroutineScope.sendMessage(
text: String,
message: Message,
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,
textState: MutableState<String>
richTextEditorState: RichTextEditorState,
) = launch {
val capturedMode = messageComposerContext.composerMode
// Reset composer right away
textState.value = ""
richTextEditorState.setHtml("")
updateComposerMode(MessageComposerMode.Normal(""))
when (capturedMode) {
is MessageComposerMode.Normal -> room.sendMessage(text)
is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html)
is MessageComposerMode.Edit -> {
val eventId = capturedMode.eventId
val transactionId = capturedMode.transactionId
room.editMessage(eventId, transactionId, text)
room.editMessage(eventId, transactionId, message.markdown, message.html)
}
is MessageComposerMode.Quote -> TODO()
is MessageComposerMode.Reply -> room.replyMessage(
capturedMode.eventId,
text
message.markdown,
message.html,
)
}
}

View file

@ -19,21 +19,23 @@ 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.textcomposer.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList
@Immutable
data class MessageComposerState(
val text: String?,
val richTextEditorState: RichTextEditorState,
val isFullScreen: Boolean,
val hasFocus: Boolean,
val mode: MessageComposerMode,
val showAttachmentSourcePicker: Boolean,
val showTextFormatting: Boolean,
val canShareLocation: Boolean,
val canCreatePoll: Boolean,
val attachmentsState: AttachmentsState,
val eventSink: (MessageComposerEvents) -> Unit
val eventSink: (MessageComposerEvents) -> Unit,
) {
val isSendButtonVisible: Boolean = text.isNullOrEmpty().not()
val canSendMessage: Boolean = richTextEditorState.messageHtml.isNotEmpty()
val hasFocus: Boolean = richTextEditorState.hasFocus
}
@Immutable

View file

@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState
open class MessageComposerStateProvider : PreviewParameterProvider<MessageComposerState> {
override val values: Sequence<MessageComposerState>
@ -27,19 +28,20 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
}
fun aMessageComposerState(
text: String = "",
requestFocus: Boolean = true,
composerState: RichTextEditorState = RichTextEditorState("", fake = true),
isFullScreen: Boolean = false,
hasFocus: Boolean = false,
mode: MessageComposerMode = MessageComposerMode.Normal(content = ""),
showTextFormatting: Boolean = false,
showAttachmentSourcePicker: Boolean = false,
canShareLocation: Boolean = true,
canCreatePoll: Boolean = true,
attachmentsState: AttachmentsState = AttachmentsState.None,
) = MessageComposerState(
text = text,
richTextEditorState = composerState.apply { if(requestFocus) requestFocus() },
isFullScreen = isFullScreen,
hasFocus = hasFocus,
mode = mode,
showTextFormatting = showTextFormatting,
showAttachmentSourcePicker = showAttachmentSourcePicker,
canShareLocation = canShareLocation,
canCreatePoll = canCreatePoll,

View file

@ -23,6 +23,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.textcomposer.Message
import io.element.android.libraries.textcomposer.TextComposer
@Composable
@ -36,7 +37,7 @@ fun MessageComposerView(
state.eventSink(MessageComposerEvents.ToggleFullScreenState)
}
fun sendMessage(message: String) {
fun sendMessage(message: Message) {
state.eventSink(MessageComposerEvents.SendMessage(message))
}
@ -48,12 +49,12 @@ fun MessageComposerView(
state.eventSink(MessageComposerEvents.CloseSpecialMode)
}
fun onComposerTextChange(text: String) {
state.eventSink(MessageComposerEvents.UpdateText(text))
fun onDismissTextFormatting() {
state.eventSink(MessageComposerEvents.ToggleTextFormatting(enabled = false))
}
fun onFocusChanged(hasFocus: Boolean) {
state.eventSink(MessageComposerEvents.FocusChanged(hasFocus))
fun onError(error: Throwable) {
state.eventSink(MessageComposerEvents.Error(error))
}
Box(modifier = modifier) {
@ -64,14 +65,16 @@ fun MessageComposerView(
)
TextComposer(
state = state.richTextEditorState,
canSendMessage = state.canSendMessage,
onRequestFocus = { state.richTextEditorState.requestFocus() },
onSendMessage = ::sendMessage,
composerMode = state.mode,
showTextFormatting = state.showTextFormatting,
onResetComposerMode = ::onCloseSpecialMode,
onComposerTextChange = ::onComposerTextChange,
onAddAttachment = ::onAddAttachment,
onFocusChanged = ::onFocusChanged,
composerCanSendMessage = state.isSendButtonVisible,
composerText = state.text
onDismissTextFormatting = ::onDismissTextFormatting,
onError = ::onError,
)
}
}

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Composable
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.wysiwyg.compose.RichTextEditorState
import io.element.android.wysiwyg.compose.rememberRichTextEditorState
import javax.inject.Inject
interface RichTextEditorStateFactory {
@Composable
fun create(): RichTextEditorState
}
@ContributesBinding(AppScope::class)
class DefaultRichTextEditorStateFactory @Inject constructor() : RichTextEditorStateFactory {
@Composable
override fun create(): RichTextEditorState {
return rememberRichTextEditorState()
}
}

View file

@ -21,10 +21,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import im.vector.app.features.analytics.plan.PollVote
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
@ -34,6 +36,7 @@ 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.canSendMessageAsState
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
@ -50,6 +53,7 @@ class TimelinePresenter @Inject constructor(
private val room: MatrixRoom,
private val dispatchers: CoroutineDispatchers,
private val appScope: CoroutineScope,
private val analyticsService: AnalyticsService,
) : Presenter<TimelineState> {
private val timeline = room.timeline
@ -61,7 +65,7 @@ class TimelinePresenter @Inject constructor(
mutableStateOf(null)
}
val lastReadReceiptIndex = rememberSaveable { mutableStateOf(Int.MAX_VALUE) }
val lastReadReceiptIndex = rememberSaveable { mutableIntStateOf(Int.MAX_VALUE) }
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
val timelineItems by timelineItemsFactory.collectItemsAsState()
@ -92,7 +96,7 @@ class TimelinePresenter @Inject constructor(
pollStartId = event.pollStartId,
answers = listOf(event.answerId),
)
// TODO Polls: Send poll vote analytic
analyticsService.capture(PollVote())
}
}
}
@ -115,11 +119,11 @@ class TimelinePresenter @Inject constructor(
return TimelineState(
highlightedEventId = highlightedEventId.value,
canReply = userHasPermissionToSendMessage,
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
paginationState = paginationState,
timelineItems = timelineItems,
hasNewItems = hasNewItems.value,
eventSink = ::handleEvents
eventSink = { handleEvents(it) }
)
}

View file

@ -26,7 +26,7 @@ import kotlinx.collections.immutable.ImmutableList
data class TimelineState(
val timelineItems: ImmutableList<TimelineItem>,
val highlightedEventId: EventId?,
val canReply: Boolean,
val userHasPermissionToSendMessage: Boolean,
val paginationState: MatrixTimeline.PaginationState,
val hasNewItems: Boolean,
val eventSink: (TimelineEvents) -> Unit

View file

@ -44,7 +44,7 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf
timelineItems = timelineItems,
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true),
highlightedEventId = null,
canReply = true,
userHasPermissionToSendMessage = true,
hasNewItems = false,
eventSink = {},
)

View file

@ -63,6 +63,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
@ -119,7 +120,7 @@ fun TimelineView(
TimelineItemRow(
timelineItem = timelineItem,
highlightedItem = state.highlightedEventId?.value,
canReply = state.canReply,
userHasPermissionToSendMessage = state.userHasPermissionToSendMessage,
onClick = onMessageClicked,
onLongClick = onMessageLongClicked,
onUserDataClick = onUserDataClicked,
@ -156,7 +157,7 @@ fun TimelineView(
fun TimelineItemRow(
timelineItem: TimelineItem,
highlightedItem: String?,
canReply: Boolean,
userHasPermissionToSendMessage: Boolean,
onUserDataClick: (UserId) -> Unit,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
@ -189,7 +190,7 @@ fun TimelineItemRow(
TimelineItemEventRow(
event = timelineItem,
isHighlighted = highlightedItem == timelineItem.identifier(),
canReply = canReply,
canReply = userHasPermissionToSendMessage && timelineItem.content.canBeRepliedTo(),
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
onUserDataClick = onUserDataClick,
@ -228,7 +229,7 @@ fun TimelineItemRow(
TimelineItemRow(
timelineItem = subGroupEvent,
highlightedItem = highlightedItem,
canReply = false,
userHasPermissionToSendMessage = false,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,

View file

@ -75,6 +75,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@ -327,6 +328,7 @@ private fun MessageSenderInformation(
) {
val avatarStrokeColor = MaterialTheme.colorScheme.background
val avatarSize = senderAvatar.size.dp
val avatarColors = AvatarColorsProvider.provide(senderAvatar.id, ElementTheme.isLightTheme)
Box(
modifier = modifier
) {
@ -350,7 +352,7 @@ private fun MessageSenderInformation(
text = sender,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary,
color = avatarColors.foreground,
style = ElementTheme.typography.fontBodyMdMedium,
)
}

View file

@ -22,34 +22,35 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import com.vanniktech.emoji.Emoji
import io.element.android.features.messages.impl.timeline.components.EmojiPicker
import io.element.android.emojibasebindings.Emoji
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.hide
import io.element.android.libraries.matrix.api.core.EventId
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomReactionBottomSheet(
state: CustomReactionState,
onEmojiSelected: (Emoji) -> Unit,
onEmojiSelected: (EventId, Emoji) -> Unit,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState()
val coroutineScope = rememberCoroutineScope()
val target = state.target as? CustomReactionState.Target.Success
fun onDismiss() {
state.eventSink(CustomReactionEvents.UpdateSelectedEvent(null))
state.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
}
fun onEmojiSelectedDismiss(emoji: Emoji) {
if (target?.event?.eventId == null) return
sheetState.hide(coroutineScope) {
state.eventSink(CustomReactionEvents.UpdateSelectedEvent(null))
onEmojiSelected(emoji)
state.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
onEmojiSelected(target.event.eventId, emoji)
}
}
val isVisible = state.selectedEventId != null
if (isVisible) {
if (target?.emojibaseStore != null && target.event.eventId != null) {
ModalBottomSheet(
onDismissRequest = ::onDismiss,
sheetState = sheetState,
@ -57,8 +58,9 @@ fun CustomReactionBottomSheet(
) {
EmojiPicker(
onEmojiSelected = ::onEmojiSelectedDismiss,
modifier = Modifier.fillMaxSize(),
emojibaseStore = target.emojibaseStore,
selectedEmojis = state.selectedEmoji,
modifier = Modifier.fillMaxSize(),
)
}
}

View file

@ -19,5 +19,6 @@ package io.element.android.features.messages.impl.timeline.components.customreac
import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface CustomReactionEvents {
data class UpdateSelectedEvent(val event: TimelineItem.Event?) : CustomReactionEvents
data class ShowCustomReactionSheet(val event: TimelineItem.Event) : CustomReactionEvents
object DismissCustomReactionSheet : CustomReactionEvents
}

View file

@ -17,28 +17,53 @@
package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.launch
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import kotlinx.collections.immutable.toImmutableSet
import javax.inject.Inject
class CustomReactionPresenter @Inject constructor() : Presenter<CustomReactionState> {
class CustomReactionPresenter @Inject constructor(
private val emojibaseProvider: EmojibaseProvider
) : Presenter<CustomReactionState> {
@Composable
override fun present(): CustomReactionState {
var selectedEvent by remember { mutableStateOf<TimelineItem.Event?>(null) }
val target: MutableState<CustomReactionState.Target> = remember {
mutableStateOf(CustomReactionState.Target.None)
}
fun handleEvents(event: CustomReactionEvents) {
when (event) {
is CustomReactionEvents.UpdateSelectedEvent -> selectedEvent = event.event
val localCoroutineScope = rememberCoroutineScope()
fun handleShowCustomReactionSheet(event: TimelineItem.Event) {
target.value = CustomReactionState.Target.Loading(event)
localCoroutineScope.launch {
target.value = CustomReactionState.Target.Success(
event = event,
emojibaseStore = emojibaseProvider.emojibaseStore
)
}
}
val selectedEmoji = selectedEvent?.reactionsState?.reactions?.mapNotNull { if(it.isHighlighted) it.key else null }.orEmpty().toImmutableSet()
return CustomReactionState(selectedEventId = selectedEvent?.eventId, selectedEmoji = selectedEmoji, eventSink = ::handleEvents)
fun handleDismissCustomReactionSheet() {
target.value = CustomReactionState.Target.None
}
fun handleEvents(event: CustomReactionEvents) {
when (event) {
is CustomReactionEvents.ShowCustomReactionSheet -> handleShowCustomReactionSheet(event.event)
is CustomReactionEvents.DismissCustomReactionSheet -> handleDismissCustomReactionSheet()
}
}
val event = (target.value as? CustomReactionState.Target.Success)?.event
val selectedEmoji = event?.reactionsState?.reactions?.mapNotNull { if(it.isHighlighted) it.key else null }.orEmpty().toImmutableSet()
return CustomReactionState(
target = target.value,
selectedEmoji = selectedEmoji,
eventSink = { handleEvents(it) }
)
}
}

View file

@ -16,11 +16,23 @@
package io.element.android.features.messages.impl.timeline.components.customreaction
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import kotlinx.collections.immutable.ImmutableSet
data class CustomReactionState(
val selectedEventId: EventId?,
val target: Target,
val selectedEmoji: ImmutableSet<String>,
val eventSink: (CustomReactionEvents) -> Unit,
)
) {
sealed interface Target {
data object None : Target
data class Loading(val event: TimelineItem.Event) : Target
data class Success(
val event: TimelineItem.Event,
val emojibaseStore: EmojibaseStore,
) : Target
}
}

View file

@ -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.impl.timeline.components.customreaction
import android.content.Context
import io.element.android.emojibasebindings.EmojibaseDatasource
import io.element.android.emojibasebindings.EmojibaseStore
class DefaultEmojibaseProvider(val context: Context): EmojibaseProvider {
override val emojibaseStore: EmojibaseStore by lazy {
EmojibaseDatasource().load(context)
}
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components
package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
@ -41,11 +41,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vanniktech.emoji.Emoji
import com.vanniktech.emoji.google.GoogleEmojiProvider
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.emojibasebindings.EmojibaseDatasource
import io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
@ -59,24 +63,23 @@ import kotlinx.coroutines.launch
@Composable
fun EmojiPicker(
onEmojiSelected: (Emoji) -> Unit,
emojibaseStore: EmojibaseStore,
selectedEmojis: ImmutableSet<String>,
modifier: Modifier = Modifier,
) {
val coroutineScope = rememberCoroutineScope()
val emojiProvider = remember { GoogleEmojiProvider() }
val categories = remember { emojiProvider.categories }
val pagerState = rememberPagerState(pageCount = { emojiProvider.categories.size })
val categories = remember { emojibaseStore.categories }
val pagerState = rememberPagerState(pageCount = { EmojibaseCategory.values().size })
Column(modifier) {
TabRow(
selectedTabIndex = pagerState.currentPage,
) {
categories.forEachIndexed { index, category ->
EmojibaseCategory.values().forEachIndexed { index, category ->
Tab(
text = {
Icon(
resourceId = emojiProvider.getIcon(category),
contentDescription = category.categoryNames["en"]
imageVector = category.icon,
contentDescription = stringResource(id = category.title)
)
},
selected = pagerState.currentPage == index,
@ -91,14 +94,16 @@ fun EmojiPicker(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
) { index ->
val category = categories[index]
val category = EmojibaseCategory.values()[index]
val emojis = categories[category] ?: listOf()
LazyVerticalGrid(
modifier = Modifier.fillMaxSize(),
columns = GridCells.Adaptive(minSize = 40.dp),
contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(category.emojis, key = { it.unicode }) { item ->
items(emojis, key = { it.unicode }) { item ->
val backgroundColor = if (selectedEmojis.contains(item.unicode)) {
ElementTheme.colors.bgActionPrimaryRest
} else {
@ -144,7 +149,8 @@ internal fun EmojiPickerDarkPreview() {
private fun ContentToPreview() {
EmojiPicker(
onEmojiSelected = {},
emojibaseStore = EmojibaseDatasource().load(LocalContext.current),
selectedEmojis = persistentSetOf("😀", "😄", "😃"),
modifier = Modifier.fillMaxWidth(),
selectedEmojis = persistentSetOf("😀", "😄", "😃")
)
}

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material.icons.outlined.EmojiFlags
import androidx.compose.material.icons.outlined.EmojiFoodBeverage
import androidx.compose.material.icons.outlined.EmojiNature
import androidx.compose.material.icons.outlined.EmojiObjects
import androidx.compose.material.icons.outlined.EmojiPeople
import androidx.compose.material.icons.outlined.EmojiSymbols
import androidx.compose.material.icons.outlined.EmojiTransportation
import androidx.compose.ui.graphics.vector.ImageVector
import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.libraries.ui.strings.CommonStrings
@get:StringRes
val EmojibaseCategory.title: Int get() =
when(this){
EmojibaseCategory.People -> CommonStrings.emoji_picker_category_people
EmojibaseCategory.Nature -> CommonStrings.emoji_picker_category_nature
EmojibaseCategory.Foods -> CommonStrings.emoji_picker_category_foods
EmojibaseCategory.Activity -> CommonStrings.emoji_picker_category_activity
EmojibaseCategory.Places -> CommonStrings.emoji_picker_category_places
EmojibaseCategory.Objects -> CommonStrings.emoji_picker_category_objects
EmojibaseCategory.Symbols -> CommonStrings.emoji_picker_category_symbols
EmojibaseCategory.Flags -> CommonStrings.emoji_picker_category_flags
}
val EmojibaseCategory.icon: ImageVector
get() =
when(this){
EmojibaseCategory.People -> Icons.Outlined.EmojiPeople
EmojibaseCategory.Nature -> Icons.Outlined.EmojiNature
EmojibaseCategory.Foods -> Icons.Outlined.EmojiFoodBeverage
EmojibaseCategory.Activity -> Icons.Outlined.EmojiEvents
EmojibaseCategory.Places -> Icons.Outlined.EmojiTransportation
EmojibaseCategory.Objects -> Icons.Outlined.EmojiObjects
EmojibaseCategory.Symbols -> Icons.Outlined.EmojiSymbols
EmojibaseCategory.Flags -> Icons.Outlined.EmojiFlags
}

View file

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

View file

@ -18,10 +18,14 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@ -69,3 +73,10 @@ fun ExtraPadding.getStr(fontSize: TextUnit): String {
// A space and some unbreakable spaces
return " " + "\u00A0".repeat(nbOfSpaces)
}
@Composable
fun ExtraPadding.getDpSize(): Dp {
if (nbChars == 0) return 0.dp
val timestampFontSize = ElementTheme.typography.fontBodyXsRegular.fontSize // 11.sp
return nbChars * timestampFontSize.toDp() / 3
}

View file

@ -18,9 +18,6 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@ -28,7 +25,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.timeline.components.html.HtmlDocument
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
@ -51,18 +47,14 @@ fun TimelineItemTextView(
CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textPrimary) {
val htmlDocument = content.htmlDocument
if (htmlDocument != null) {
// For now we ignore the extra padding for html content, so add some spacing
// below the content (as previous behavior)
Column(modifier = modifier) {
HtmlDocument(
document = htmlDocument,
modifier = Modifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
Spacer(Modifier.height(16.dp))
}
HtmlDocument(
document = htmlDocument,
extraPadding = extraPadding,
modifier = modifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
} else {
Box(modifier) {
val textWithPadding = remember(content.body) {

View file

@ -25,8 +25,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
@ -53,13 +55,18 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.timeline.components.event.ExtraPadding
import io.element.android.features.messages.impl.timeline.components.event.getDpSize
import io.element.android.features.messages.impl.timeline.components.event.noExtraPadding
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.theme.LinkColor
import kotlinx.collections.immutable.persistentMapOf
import org.jsoup.nodes.Document
@ -72,18 +79,28 @@ private const val CHIP_ID = "chip"
@Composable
fun HtmlDocument(
document: Document,
extraPadding: ExtraPadding,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit = {},
onTextLongClicked: () -> Unit = {},
) {
HtmlBody(
body = document.body(),
interactionSource = interactionSource,
FlowRow(
modifier = modifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
)
) {
HtmlBody(
body = document.body(),
interactionSource = interactionSource,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
)
Spacer(
modifier = Modifier.size(
width = extraPadding.getDpSize(),
height = ElementTheme.typography.fontBodyXsRegular.fontSize.toDp() * 1.25f
)
)
}
}
@Composable
@ -603,5 +620,9 @@ internal fun HtmlDocumentDarkPreview(@PreviewParameter(DocumentProvider::class)
@Composable
private fun ContentToPreview(document: Document) {
HtmlDocument(document, remember { MutableInteractionSource() })
HtmlDocument(
document = document,
extraPadding = noExtraPadding,
interactionSource = remember { MutableInteractionSource() }
)
}

View file

@ -61,7 +61,7 @@ class ReactionSummaryPresenter @Inject constructor(
}
return ReactionSummaryState(
target = targetWithAvatars.value,
eventSink = ::handleEvents
eventSink = { handleEvents(it) }
)
}

View file

@ -66,7 +66,7 @@ class RetrySendMenuPresenter @Inject constructor(
return RetrySendMenuState(
selectedEvent = selectedEvent,
eventSink = ::handleEvent,
eventSink = { handleEvent(it) },
)
}
}

View file

@ -132,10 +132,12 @@ class TimelineItemContentMessageFactory @Inject constructor(
}
private fun aspectRatioOf(width: Long?, height: Long?): Float? {
return if (height != null && width != null) {
val result = if (height != null && width != null) {
width.toFloat() / height.toFloat()
} else {
null
}
return result?.takeIf { it.isFinite() }
}
}

View file

@ -34,6 +34,18 @@ fun TimelineItemEventContent.canBeCopied(): Boolean =
else -> false
}
/**
* Determine if the event content can be replied to.
* Note: it should match the logic in [io.element.android.features.messages.impl.actionlist.ActionListPresenter].
*/
fun TimelineItemEventContent.canBeRepliedTo(): Boolean =
when (this) {
is TimelineItemRedactedContent,
is TimelineItemStateContent,
is TimelineItemPollContent -> false
else -> true
}
/**
* Return true if user can react (i.e. send a reaction) on the event content.
*/

View file

@ -18,6 +18,10 @@ package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.location.api.Location
import io.element.android.features.poll.api.PollAnswerItem
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind
open class TimelineItemLocationContentProvider : PreviewParameterProvider<TimelineItemLocationContent> {
override val values: Sequence<TimelineItemLocationContent>
@ -36,3 +40,32 @@ fun aTimelineItemLocationContent(description: String? = null) = TimelineItemLoca
),
description = description,
)
fun aTimelineItemPollContent(
isEnded: Boolean = false,
) = TimelineItemPollContent(
eventId = EventId("\$anEventId"),
question = "Some question?",
answerItems = listOf(
PollAnswerItem(
answer = PollAnswer("id_1", "Answer1"),
isSelected = false,
isEnabled = false,
isWinner = false,
isDisclosed = false,
votesCount = 0,
percentage = 0.0f,
),
PollAnswerItem(
answer = PollAnswer("id_2", "Answer2"),
isSelected = false,
isEnabled = false,
isWinner = false,
isDisclosed = false,
votesCount = 0,
percentage = 0.0f,
),
),
pollKind = PollKind.Disclosed,
isEnded = isEnded,
)

View file

@ -22,4 +22,6 @@ sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
val body: String
val htmlDocument: Document?
val isEdited: Boolean
val htmlBody: String?
get() = htmlDocument?.body()?.html()
}

View file

@ -5,11 +5,6 @@
<item quantity="few">"%1$d změny místnosti"</item>
<item quantity="other">"%1$d změn místnosti"</item>
</plurals>
<plurals name="screen_room_timeline_more_reactions">
<item quantity="one">"%1$d další"</item>
<item quantity="few">"%1$d další"</item>
<item quantity="other">"%1$d dalších"</item>
</plurals>
<string name="screen_room_attachment_source_camera">"Fotoaparát"</string>
<string name="screen_room_attachment_source_camera_photo">"Vyfotit"</string>
<string name="screen_room_attachment_source_camera_video">"Natočit video"</string>

View file

@ -4,10 +4,6 @@
<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">"%1$d de plus"</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>

View file

@ -11,13 +11,33 @@
<string name="screen_room_attachment_source_files">"Atașament"</string>
<string name="screen_room_attachment_source_gallery">"Bibliotecă foto și video"</string>
<string name="screen_room_attachment_source_location">"Locație"</string>
<string name="screen_room_attachment_source_poll">"Sondaj"</string>
<string name="screen_room_attachment_text_formatting">"Formatarea textului"</string>
<string name="screen_room_encrypted_history_banner">"Istoricul mesajelor este momentan indisponibil în această cameră"</string>
<string name="screen_room_error_failed_retrieving_user_details">"Nu am putut găsi detaliile utilizatorului"</string>
<string name="screen_room_invite_again_alert_message">"Doriți să îi invitați înapoi?"</string>
<string name="screen_room_invite_again_alert_title">"Sunteți singur în această cameră"</string>
<string name="screen_room_message_copied">"Mesaj copiat"</string>
<string name="screen_room_no_permission_to_post">"Nu aveți permisiunea de a posta în această cameră"</string>
<string name="screen_room_notification_settings_allow_custom">"Permiteți setări personalizate"</string>
<string name="screen_room_notification_settings_allow_custom_footnote">"Activarea acestei opțiuni va anula setările implicite."</string>
<string name="screen_room_notification_settings_custom_settings_title">"Anunțați-mă în acestă cameră pentru"</string>
<string name="screen_room_notification_settings_default_setting_footnote">"Îl puteți schimba în %1$s."</string>
<string name="screen_room_notification_settings_default_setting_footnote_content_link">"Setări generale"</string>
<string name="screen_room_notification_settings_default_setting_title">"Setare implicită"</string>
<string name="screen_room_notification_settings_edit_remove_setting">"Stergeți setarea personalizată"</string>
<string name="screen_room_notification_settings_error_loading_settings">"A apărut o eroare la încărcarea setărilor pentry notificari."</string>
<string name="screen_room_notification_settings_error_restoring_default">"Nu s-a reușit restaurarea modului implicit, vă rugăm să încercați din nou."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Nu s-a reușit setarea modului, vă rugăm să încercați din nou."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Toate mesajele"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Numai mențiuni și cuvinte cheie"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"În această cameră, anunțați-mă pentru"</string>
<string name="screen_room_reactions_show_less">"Afișați mai puțin"</string>
<string name="screen_room_reactions_show_more">"Afișați mai mult"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Trimiteți din nou"</string>
<string name="screen_room_retry_send_menu_title">"Mesajul dvs. nu a putut fi trimis"</string>
<string name="screen_room_timeline_add_reaction">"Adăugați emoji"</string>
<string name="screen_room_timeline_less_reactions">"Afișați mai puțin"</string>
<string name="screen_room_error_failed_processing_media">"Procesarea datelor media a eșuat, vă rugăm să încercați din nou."</string>
<string name="screen_room_retry_send_menu_remove_action">"Ștergeți"</string>
</resources>

View file

@ -5,11 +5,6 @@
<item quantity="few">"%1$d изменения в комнате"</item>
<item quantity="many">"%1$d изменений в комнате"</item>
</plurals>
<plurals name="screen_room_timeline_more_reactions">
<item quantity="one">"И ещё %1$d"</item>
<item quantity="few">"И ещё %1$d"</item>
<item quantity="many">"И ещё %1$d"</item>
</plurals>
<string name="screen_room_attachment_source_camera">"Камера"</string>
<string name="screen_room_attachment_source_camera_photo">"Сделать фото"</string>
<string name="screen_room_attachment_source_camera_video">"Записать видео"</string>

View file

@ -5,11 +5,6 @@
<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">"%1$d ďalší"</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>

View file

@ -3,9 +3,6 @@
<plurals name="room_timeline_state_changes">
<item quantity="other">"%1$d 個聊天室變更"</item>
</plurals>
<plurals name="screen_room_timeline_more_reactions">
<item quantity="other">"還有 %1$d 個"</item>
</plurals>
<string name="screen_room_attachment_source_camera">"照相機"</string>
<string name="screen_room_attachment_source_camera_photo">"拍照"</string>
<string name="screen_room_attachment_source_camera_video">"錄影"</string>

View file

@ -4,9 +4,6 @@
<item quantity="one">"%1$d room change"</item>
<item quantity="other">"%1$d room changes"</item>
</plurals>
<plurals name="screen_room_timeline_more_reactions">
<item quantity="other">"%1$d more"</item>
</plurals>
<string name="screen_room_attachment_source_camera">"Camera"</string>
<string name="screen_room_attachment_source_camera_photo">"Take photo"</string>
<string name="screen_room_attachment_source_camera_video">"Record a video"</string>
@ -14,6 +11,7 @@
<string name="screen_room_attachment_source_gallery">"Photo &amp; Video Library"</string>
<string name="screen_room_attachment_source_location">"Location"</string>
<string name="screen_room_attachment_source_poll">"Poll"</string>
<string name="screen_room_attachment_text_formatting">"Text Formatting"</string>
<string name="screen_room_encrypted_history_banner">"Message history is currently unavailable in this room"</string>
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
<string name="screen_room_invite_again_alert_message">"Would you like to invite them back?"</string>

View file

@ -21,6 +21,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.PollEnd
import io.element.android.features.messages.fixtures.aMessageEvent
import io.element.android.features.messages.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.InviteDialogAction
@ -30,7 +31,6 @@ 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
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
@ -41,6 +41,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.features.messages.textcomposer.TestRichTextEditorStateFactory
import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
@ -50,6 +52,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -69,16 +72,24 @@ import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.consumeItemsUntilTimeout
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.element.android.tests.testutils.waitForPredicate
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
class MessagesPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private val mockMediaUrl: Uri = mockk("localMediaUri")
@Test
@ -315,6 +326,7 @@ class MessagesPresenterTest {
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent()))
assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID)
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
skipItems(1) // back paginating
}
}
@ -371,14 +383,14 @@ class MessagesPresenterTest {
// Initially the composer doesn't have focus, so we don't show the alert
assertThat(initialState.showReinvitePrompt).isFalse()
// When the input field is focused we show the alert
initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
val focusedState = consumeItemsUntilPredicate { state ->
initialState.composerState.richTextEditorState.requestFocus()
val focusedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) { state ->
state.showReinvitePrompt
}.last()
assertThat(focusedState.showReinvitePrompt).isTrue()
// If it's dismissed then we stop showing the alert
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Cancel))
val dismissedState = consumeItemsUntilPredicate { state ->
val dismissedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) { state ->
!state.showReinvitePrompt
}.last()
assertThat(dismissedState.showReinvitePrompt).isFalse()
@ -395,7 +407,7 @@ class MessagesPresenterTest {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.showReinvitePrompt).isFalse()
initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
initialState.composerState.richTextEditorState.requestFocus()
val focusedState = awaitItem()
assertThat(focusedState.showReinvitePrompt).isFalse()
}
@ -411,7 +423,7 @@ class MessagesPresenterTest {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.showReinvitePrompt).isFalse()
initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
initialState.composerState.richTextEditorState.requestFocus()
val focusedState = awaitItem()
assertThat(focusedState.showReinvitePrompt).isFalse()
}
@ -462,7 +474,9 @@ class MessagesPresenterTest {
val initialState = consumeItemsUntilTimeout().last()
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
skipItems(1)
val loadingState = awaitItem()
val loadingState = consumeItemsUntilPredicate { state ->
state.inviteProgress.isLoading()
}.last()
assertThat(loadingState.inviteProgress.isLoading()).isTrue()
val newState = awaitItem()
assertThat(newState.inviteProgress.isSuccess()).isTrue()
@ -559,32 +573,59 @@ class MessagesPresenterTest {
}
}
@Test
fun `present - handle poll end`() = runTest {
val room = FakeMatrixRoom()
val analyticsService = FakeAnalyticsService()
val presenter = createMessagePresenter(
matrixRoom = room,
analyticsService = analyticsService,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EndPoll, aMessageEvent()))
waitForPredicate { room.endPollInvocations.size == 1 }
cancelAndIgnoreRemainingEvents()
assertThat(room.endPollInvocations.size).isEqualTo(1)
assertThat(room.endPollInvocations.first().pollStartId).isEqualTo(AN_EVENT_ID)
assertThat(room.endPollInvocations.first().text).isEqualTo("The poll with event id: \$anEventId has ended.")
assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
assertThat(analyticsService.capturedEvents.last()).isEqualTo(PollEnd())
}
}
private fun TestScope.createMessagePresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
matrixRoom: MatrixRoom = FakeMatrixRoom(),
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
): MessagesPresenter {
val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this,
room = matrixRoom,
mediaPickerProvider = FakePickerProvider(),
featureFlagService = FakeFeatureFlagService(),
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.NotificationSettings.key to true)),
localMediaFactory = FakeLocalMediaFactory(mockMediaUrl),
mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom),
snackbarDispatcher = SnackbarDispatcher(),
analyticsService = FakeAnalyticsService(),
analyticsService = analyticsService,
messageComposerContext = MessageComposerContextImpl(),
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
)
val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
room = matrixRoom,
dispatchers = coroutineDispatchers,
appScope = this
appScope = this,
analyticsService = analyticsService,
)
val buildMeta = aBuildMeta()
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
val customReactionPresenter = CustomReactionPresenter()
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom)
return MessagesPresenter(
@ -600,6 +641,7 @@ class MessagesPresenterTest {
messageSummaryFormatter = FakeMessageSummaryFormatter(),
navigator = navigator,
clipboardHelper = clipboardHelper,
analyticsService = analyticsService,
dispatchers = coroutineDispatchers,
)
}

View file

@ -26,22 +26,25 @@ 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.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.poll.api.PollAnswerItem
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ActionListPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = true)
@ -61,7 +64,7 @@ class ActionListPresenterTest {
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(isMine = true, content = TimelineItemRedactedContent)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
@ -86,7 +89,7 @@ class ActionListPresenterTest {
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(isMine = false, content = TimelineItemRedactedContent)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
@ -114,7 +117,7 @@ class ActionListPresenterTest {
isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
@ -135,6 +138,37 @@ class ActionListPresenterTest {
}
}
@Test
fun `present - compute for others message cannot sent message`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = false))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
messageEvent,
persistentListOf(
TimelineItemAction.Forward,
TimelineItemAction.Copy,
TimelineItemAction.Developer,
TimelineItemAction.ReportContent,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for others message and can redact`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = true)
@ -146,7 +180,7 @@ class ActionListPresenterTest {
isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, true))
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = true, canSendMessage = true))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
@ -177,7 +211,7 @@ class ActionListPresenterTest {
isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
@ -210,7 +244,7 @@ class ActionListPresenterTest {
isMine = true,
content = aTimelineItemImageContent(),
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
@ -241,7 +275,7 @@ class ActionListPresenterTest {
isMine = true,
content = aTimelineItemStateEventContent(),
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, false))
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
@ -270,7 +304,7 @@ class ActionListPresenterTest {
isMine = true,
content = aTimelineItemStateEventContent(),
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, false))
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
@ -298,7 +332,7 @@ class ActionListPresenterTest {
isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
@ -335,10 +369,10 @@ class ActionListPresenterTest {
content = TimelineItemRedactedContent,
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(redactedEvent, false))
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(redactedEvent, canRedact = false, canSendMessage = true))
awaitItem().run {
assertThat(target).isEqualTo(ActionListState.Target.None)
assertThat(displayEmojiReactions).isFalse()
@ -359,7 +393,7 @@ class ActionListPresenterTest {
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
@ -384,35 +418,35 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = TimelineItemPollContent(
eventId = EventId("\$anEventId"),
question = "Some question?",
answerItems = listOf(
PollAnswerItem(
answer = PollAnswer("id_1", "Answer1"),
isSelected = false,
isEnabled = false,
isWinner = false,
isDisclosed = false,
votesCount = 0,
percentage = 0.0f,
),
PollAnswerItem(
answer = PollAnswer("id_2", "Answer2"),
isSelected = false,
isEnabled = false,
isWinner = false,
isDisclosed = false,
votesCount = 0,
percentage = 0.0f,
),
),
pollKind = PollKind.Disclosed,
isEnded = false,
content = aTimelineItemPollContent(),
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
messageEvent,
persistentListOf(
TimelineItemAction.EndPoll,
TimelineItemAction.Redact,
)
)
)
assertThat(successState.displayEmojiReactions).isTrue()
}
}
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
@Test
fun `present - compute for ended poll message`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = false)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = aTimelineItemPollContent(isEnded = true),
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(

View file

@ -34,13 +34,19 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.tests.testutils.WarmUpRule
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class AttachmentsPreviewPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private val mediaPreProcessor = FakeMediaPreProcessor()
private val mockMediaUrl: Uri = mockk("localMediaUri")

View file

@ -30,13 +30,20 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ForwardMessagesPresenterTests {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = aPresenter()

View file

@ -33,15 +33,22 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.tests.testutils.WarmUpRule
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
private val TESTED_MEDIA_INFO = aFileInfo()
class MediaViewerPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private val mockMediaUri: Uri = mockk("localMediaUri")
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri)

View file

@ -28,11 +28,17 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ReportMessagePresenterTests {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `presenter - initial state`() = runTest {
val presenter = aPresenter()

View file

@ -53,17 +53,24 @@ import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.textcomposer.Message
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import java.io.File
class MessageComposerPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private val pickerProvider = FakePickerProvider().apply {
givenResult(mockk()) // Uri is not available in JVM, so the only way to have a non-null Uri is using Mockk
}
@ -74,6 +81,7 @@ class MessageComposerPresenterTest {
private val snackbarDispatcher = SnackbarDispatcher()
private val mockMediaUrl: Uri = mockk("localMediaUri")
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl)
private val analyticsService = FakeAnalyticsService()
@Test
fun `present - initial state`() = runTest {
@ -84,12 +92,12 @@ class MessageComposerPresenterTest {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isFullScreen).isFalse()
assertThat(initialState.text).isEqualTo("")
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
assertThat(initialState.showAttachmentSourcePicker).isFalse()
assertThat(initialState.canShareLocation).isTrue()
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
assertThat(initialState.isSendButtonVisible).isFalse()
assertThat(initialState.canSendMessage).isFalse()
}
}
@ -118,14 +126,14 @@ class MessageComposerPresenterTest {
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
initialState.richTextEditorState.setHtml(A_MESSAGE)
val withMessageState = awaitItem()
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(""))
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
assertThat(withMessageState.canSendMessage).isTrue()
withMessageState.richTextEditorState.setHtml("")
val withEmptyMessageState = awaitItem()
assertThat(withEmptyMessageState.text).isEqualTo("")
assertThat(withEmptyMessageState.isSendButtonVisible).isFalse()
assertThat(withEmptyMessageState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(withEmptyMessageState.canSendMessage).isFalse()
}
}
@ -142,8 +150,8 @@ class MessageComposerPresenterTest {
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
state = awaitItem()
assertThat(state.text).isEqualTo(A_MESSAGE)
assertThat(state.isSendButtonVisible).isTrue()
assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
assertThat(state.canSendMessage).isTrue()
backToNormalMode(state, skipCount = 1)
}
}
@ -160,8 +168,8 @@ class MessageComposerPresenterTest {
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
assertThat(state.text).isEqualTo("")
assertThat(state.isSendButtonVisible).isFalse()
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
assertThat(state.canSendMessage).isFalse()
backToNormalMode(state)
}
}
@ -178,8 +186,8 @@ class MessageComposerPresenterTest {
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
assertThat(state.text).isEqualTo("")
assertThat(state.isSendButtonVisible).isFalse()
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
assertThat(state.canSendMessage).isFalse()
backToNormalMode(state)
}
}
@ -192,14 +200,14 @@ class MessageComposerPresenterTest {
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
initialState.richTextEditorState.setHtml(A_MESSAGE)
val withMessageState = awaitItem()
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE))
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
assertThat(withMessageState.canSendMessage).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo("")
assertThat(messageSentState.isSendButtonVisible).isFalse()
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(messageSentState.canSendMessage).isFalse()
}
}
@ -215,23 +223,23 @@ class MessageComposerPresenterTest {
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.text).isEqualTo("")
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
val mode = anEditMode()
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
skipItems(1)
val withMessageState = awaitItem()
assertThat(withMessageState.mode).isEqualTo(mode)
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
assertThat(withMessageState.canSendMessage).isTrue()
withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE)
val withEditedMessageState = awaitItem()
assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE)
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE)
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage()))
skipItems(1)
val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo("")
assertThat(messageSentState.isSendButtonVisible).isFalse()
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(messageSentState.canSendMessage).isFalse()
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE)
}
}
@ -247,23 +255,23 @@ class MessageComposerPresenterTest {
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.text).isEqualTo("")
assertThat(initialState.richTextEditorState.messageHtml).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(A_MESSAGE)
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
assertThat(withMessageState.canSendMessage).isTrue()
withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE)
val withEditedMessageState = awaitItem()
assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE)
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE)
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage()))
skipItems(1)
val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo("")
assertThat(messageSentState.isSendButtonVisible).isFalse()
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(messageSentState.canSendMessage).isFalse()
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE)
}
}
@ -279,23 +287,23 @@ class MessageComposerPresenterTest {
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.text).isEqualTo("")
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
val mode = aReplyMode()
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
val state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
assertThat(state.text).isEqualTo("")
assertThat(state.isSendButtonVisible).isFalse()
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_REPLY))
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
assertThat(state.canSendMessage).isFalse()
state.richTextEditorState.setHtml(A_REPLY)
val withMessageState = awaitItem()
assertThat(withMessageState.text).isEqualTo(A_REPLY)
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY))
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_REPLY)
assertThat(withMessageState.canSendMessage).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage()))
skipItems(1)
val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo("")
assertThat(messageSentState.isSendButtonVisible).isFalse()
assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY)
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(messageSentState.canSendMessage).isFalse()
assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY to A_REPLY)
}
}
@ -517,13 +525,50 @@ class MessageComposerPresenterTest {
}
}
@Test
fun `present - errors are tracked`() = runTest {
val testException = Exception("Test error")
val presenter = createPresenter(this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.Error(testException))
assertThat(analyticsService.trackedErrors).containsExactly(testException)
}
}
@Test
fun `present - ToggleTextFormatting toggles text formatting`() = runTest {
val presenter = createPresenter(this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.showTextFormatting).isFalse()
initialState.eventSink(MessageComposerEvents.AddAttachment)
val composerOptions = awaitItem()
assertThat(composerOptions.showAttachmentSourcePicker).isTrue()
composerOptions.eventSink(MessageComposerEvents.ToggleTextFormatting(true))
awaitItem() // composer options closed
val showTextFormatting = awaitItem()
assertThat(showTextFormatting.showAttachmentSourcePicker).isFalse()
assertThat(showTextFormatting.showTextFormatting).isTrue()
showTextFormatting.eventSink(MessageComposerEvents.ToggleTextFormatting(false))
val finished = awaitItem()
assertThat(finished.showTextFormatting).isFalse()
}
}
private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) {
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
skipItems(skipCount)
val normalState = awaitItem()
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
assertThat(normalState.text).isEqualTo("")
assertThat(normalState.isSendButtonVisible).isFalse()
assertThat(normalState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(normalState.canSendMessage).isFalse()
}
private fun createPresenter(
@ -541,8 +586,9 @@ class MessageComposerPresenterTest {
localMediaFactory,
MediaSender(mediaPreProcessor, room),
snackbarDispatcher,
FakeAnalyticsService(),
analyticsService,
MessageComposerContextImpl(),
TestRichTextEditorStateFactory(),
)
}
@ -554,3 +600,8 @@ fun anEditMode(
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE)
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)
private fun String.toMessage() = Message(
html = this,
markdown = this,
)

View file

@ -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.textcomposer
import androidx.compose.runtime.Composable
import io.element.android.features.messages.impl.messagecomposer.RichTextEditorStateFactory
import io.element.android.wysiwyg.compose.RichTextEditorState
import io.element.android.wysiwyg.compose.rememberRichTextEditorState
class TestRichTextEditorStateFactory : RichTextEditorStateFactory {
@Composable
override fun create(): RichTextEditorState {
return rememberRichTextEditorState("", fake = true)
}
}

View file

@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.PollVote
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
@ -37,15 +38,23 @@ import io.element.android.libraries.matrix.test.room.aMessageContent
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitWithLatch
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import java.util.Date
class TimelinePresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createTimelinePresenter()
@ -253,7 +262,11 @@ class TimelinePresenterTest {
@Test
fun `present - PollAnswerSelected event calls into rust room api and analytics`() = runTest {
val room = FakeMatrixRoom()
val presenter = createTimelinePresenter(room)
val analyticsService = FakeAnalyticsService()
val presenter = createTimelinePresenter(
room = room,
analyticsService = analyticsService,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -264,7 +277,8 @@ class TimelinePresenterTest {
assertThat(room.sendPollResponseInvocations.size).isEqualTo(1)
assertThat(room.sendPollResponseInvocations.first().answers).isEqualTo(listOf("anAnswerId"))
assertThat(room.sendPollResponseInvocations.first().pollStartId).isEqualTo(AN_EVENT_ID)
// TODO Polls: Test poll vote analytic
assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
assertThat(analyticsService.capturedEvents.last()).isEqualTo(PollVote())
}
private fun TestScope.createTimelinePresenter(
@ -275,18 +289,21 @@ class TimelinePresenterTest {
timelineItemsFactory = timelineItemsFactory,
room = FakeMatrixRoom(matrixTimeline = timeline),
dispatchers = testCoroutineDispatchers(),
appScope = this
appScope = this,
analyticsService = FakeAnalyticsService(),
)
}
private fun TestScope.createTimelinePresenter(
room: MatrixRoom,
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
room = room,
dispatchers = testCoroutineDispatchers(),
appScope = this
appScope = this,
analyticsService = analyticsService,
)
}
}

View file

@ -24,27 +24,40 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class CustomReactionPresenterTests {
private val presenter = CustomReactionPresenter()
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private val presenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
@Test
fun `present - handle selecting and de-selecting an event`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val event = aTimelineItemEvent(eventId = AN_EVENT_ID)
val initialState = awaitItem()
assertThat(initialState.selectedEventId).isNull()
assertThat(initialState.target).isEqualTo(CustomReactionState.Target.None)
initialState.eventSink(CustomReactionEvents.UpdateSelectedEvent(aTimelineItemEvent(eventId = AN_EVENT_ID)))
assertThat(awaitItem().selectedEventId).isEqualTo(AN_EVENT_ID)
initialState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
initialState.eventSink(CustomReactionEvents.UpdateSelectedEvent(null))
assertThat(awaitItem().selectedEventId).isNull()
assertThat(awaitItem().target).isEqualTo(CustomReactionState.Target.Loading(event))
val eventId = (awaitItem().target as? CustomReactionState.Target.Success)?.event?.eventId
assertThat(eventId).isEqualTo(AN_EVENT_ID)
initialState.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
assertThat(awaitItem().target).isEqualTo(CustomReactionState.Target.None)
}
}
@ -53,13 +66,19 @@ class CustomReactionPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.selectedEventId).isNull()
val reactions = aTimelineItemReactions(count = 1, isHighlighted = true)
val event = aTimelineItemEvent(eventId = AN_EVENT_ID, timelineItemReactions = reactions)
val initialState = awaitItem()
assertThat(initialState.target).isEqualTo(CustomReactionState.Target.None)
val key = reactions.reactions.first().key
initialState.eventSink(CustomReactionEvents.UpdateSelectedEvent(aTimelineItemEvent(eventId = AN_EVENT_ID, timelineItemReactions = reactions)))
initialState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
assertThat(awaitItem().target).isEqualTo(CustomReactionState.Target.Loading(event))
val stateWithSelectedEmojis = awaitItem()
assertThat(stateWithSelectedEmojis.selectedEventId).isEqualTo(AN_EVENT_ID)
val eventId = (stateWithSelectedEmojis.target as? CustomReactionState.Target.Success)?.event?.eventId
assertThat(eventId).isEqualTo(AN_EVENT_ID)
assertThat(stateWithSelectedEmojis.selectedEmoji).contains(key)
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.timeline.components.customreaction
import io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.features.messages.impl.timeline.components.customreaction.EmojibaseProvider
class FakeEmojibaseProvider: EmojibaseProvider {
override val emojibaseStore: EmojibaseStore
get() = EmojibaseStore(mapOf())
}

View file

@ -30,10 +30,17 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ReactionSummaryPresenterTests {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private val aggregatedReaction = anAggregatedReaction(userId = A_USER_ID, key = "👍", isHighlighted = true)
private val roomMember = aRoomMember(userId = A_USER_ID, avatarUrl = AN_AVATAR_URL, displayName = A_USER_NAME)
private val summaryEvent = ReactionSummaryEvents.ShowReactionSummary(AN_EVENT_ID, listOf(aggregatedReaction), aggregatedReaction.key)

View file

@ -25,11 +25,17 @@ import io.element.android.features.messages.impl.timeline.components.retrysendme
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class RetrySendMenuPresenterTests {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private val room = FakeMatrixRoom()
private val presenter = RetrySendMenuPresenter(room)

Some files were not shown because too many files have changed in this diff Show more