Merge branch 'develop' into feature/fga/room_list_filters

This commit is contained in:
ganfra 2024-02-16 11:30:20 +01:00
commit ffec57e455
1489 changed files with 5726 additions and 4385 deletions

View file

@ -57,7 +57,6 @@ import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@ -67,7 +66,6 @@ fun AnalyticsOptInView(
onClickTerms: () -> Unit,
modifier: Modifier = Modifier,
) {
LogCompositions(tag = "Analytics", msg = "Root")
val eventSink = state.eventSink
fun onTermsAccepted() {
@ -101,10 +99,8 @@ private const val LINK_TAG = "link"
private fun AnalyticsOptInHeader(
state: AnalyticsOptInState,
onClickTerms: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
IconTitleSubtitleMolecule(
@ -141,24 +137,22 @@ private fun AnalyticsOptInHeader(
}
@Composable
private fun CheckIcon(modifier: Modifier = Modifier) {
private fun CheckIcon() {
Icon(
modifier = modifier
modifier = Modifier
.size(20.dp)
.background(color = MaterialTheme.colorScheme.background, shape = CircleShape)
.padding(2.dp),
imageVector = CompoundIcons.Check,
imageVector = CompoundIcons.Check(),
contentDescription = null,
tint = ElementTheme.colors.textActionAccent,
)
}
@Composable
private fun AnalyticsOptInContent(
modifier: Modifier = Modifier,
) {
private fun AnalyticsOptInContent() {
Box(
modifier = modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize(),
contentAlignment = BiasAlignment(
horizontalBias = 0f,
verticalBias = -0.4f
@ -190,11 +184,8 @@ private fun AnalyticsOptInContent(
private fun AnalyticsOptInFooter(
onTermsAccepted: () -> Unit,
onTermsDeclined: () -> Unit,
modifier: Modifier = Modifier,
) {
ButtonColumnMolecule(
modifier = modifier,
) {
ButtonColumnMolecule {
Button(
text = stringResource(id = CommonStrings.action_ok),
onClick = onTermsAccepted,

View file

@ -64,7 +64,7 @@ internal fun CallScreenView(
title = { Text(stringResource(R.string.element_call)) },
navigationIcon = {
BackButton(
imageVector = CompoundIcons.Close,
imageVector = CompoundIcons.Close(),
onClick = { state.eventSink(CallScreenEvents.Hangup) }
)
}

View file

@ -88,10 +88,8 @@ private fun AddPeopleViewTopBar(
hasSelectedUsers: Boolean,
onBackPressed: () -> Unit,
onNextPressed: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
modifier = modifier,
title = {
Text(
text = stringResource(id = R.string.screen_create_room_add_people_title),

View file

@ -18,7 +18,6 @@ package io.element.android.features.createroom.impl.configureroom
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@ -38,8 +37,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
@ -52,6 +49,7 @@ import io.element.android.libraries.designsystem.components.LabelledTextField
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
@ -173,10 +171,8 @@ private fun ConfigureRoomToolbar(
isNextActionEnabled: Boolean,
onBackPressed: () -> Unit,
onNextPressed: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
modifier = modifier,
title = {
Text(
text = stringResource(R.string.screen_create_room_title),
@ -259,13 +255,6 @@ private fun RoomPrivacyOptions(
}
}
private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
pointerInput(Unit) {
detectTapGestures(onTap = {
focusManager.clearFocus()
})
}
@PreviewsDayNight
@Composable
internal fun ConfigureRoomViewPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = ElementPreview {

View file

@ -33,18 +33,18 @@ data class RoomPrivacyItem(
@Composable
fun roomPrivacyItems(): ImmutableList<RoomPrivacyItem> {
return RoomPrivacy.values()
return RoomPrivacy.entries
.map {
when (it) {
RoomPrivacy.Private -> RoomPrivacyItem(
privacy = it,
icon = CompoundDrawables.ic_lock,
icon = CompoundDrawables.ic_compound_lock_solid,
title = stringResource(R.string.screen_create_room_private_option_title),
description = stringResource(R.string.screen_create_room_private_option_description),
)
RoomPrivacy.Public -> RoomPrivacyItem(
privacy = it,
icon = CompoundDrawables.ic_public,
icon = CompoundDrawables.ic_compound_public,
title = stringResource(R.string.screen_create_room_public_option_title),
description = stringResource(R.string.screen_create_room_public_option_description),
)

View file

@ -117,10 +117,8 @@ fun CreateRoomRootView(
@Composable
private fun CreateRoomRootViewTopBar(
onClosePressed: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
modifier = modifier,
title = {
Text(
text = stringResource(id = CommonStrings.action_start_chat),
@ -129,7 +127,7 @@ private fun CreateRoomRootViewTopBar(
},
navigationIcon = {
BackButton(
imageVector = CompoundIcons.Close,
imageVector = CompoundIcons.Close(),
onClick = onClosePressed,
)
}
@ -141,16 +139,15 @@ private fun CreateRoomActionButtonsList(
state: CreateRoomRootState,
onNewRoomClicked: () -> Unit,
onInvitePeopleClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Column {
CreateRoomActionButton(
iconRes = CompoundDrawables.ic_plus,
iconRes = CompoundDrawables.ic_compound_plus,
text = stringResource(id = R.string.screen_create_room_action_create_room),
onClick = onNewRoomClicked,
)
CreateRoomActionButton(
iconRes = CompoundDrawables.ic_share_android,
iconRes = CompoundDrawables.ic_compound_share_android,
text = stringResource(id = CommonStrings.action_invite_friends_to_app, state.applicationName),
onClick = onInvitePeopleClicked,
)
@ -162,10 +159,9 @@ private fun CreateRoomActionButton(
@DrawableRes iconRes: Int,
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.clickable { onClick() }

View file

@ -33,7 +33,6 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.impl.migration.MigrationScreenNode
import io.element.android.features.ftue.impl.notifications.NotificationsOptInNode
import io.element.android.features.ftue.impl.state.DefaultFtueState
import io.element.android.features.ftue.impl.state.FtueStep
@ -74,9 +73,6 @@ class FtueFlowNode @AssistedInject constructor(
@Parcelize
data object Placeholder : NavTarget
@Parcelize
data object MigrationScreen : NavTarget
@Parcelize
data object WelcomeScreen : NavTarget
@ -114,14 +110,6 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.Placeholder -> {
createNode<PlaceholderNode>(buildContext)
}
NavTarget.MigrationScreen -> {
val callback = object : MigrationScreenNode.Callback {
override fun onMigrationFinished() {
lifecycleScope.launch { moveToNextStep() }
}
}
createNode<MigrationScreenNode>(buildContext, listOf(callback))
}
NavTarget.WelcomeScreen -> {
val callback = object : WelcomeNode.Callback {
override fun onContinueClicked() {
@ -158,9 +146,6 @@ class FtueFlowNode @AssistedInject constructor(
private fun moveToNextStep() {
when (ftueState.getNextStep()) {
FtueStep.MigrationScreen -> {
backstack.newRoot(NavTarget.MigrationScreen)
}
FtueStep.WelcomeScreen -> {
backstack.newRoot(NavTarget.WelcomeScreen)
}

View file

@ -1,52 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.migration
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class MigrationScreenNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: MigrationScreenPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onMigrationFinished()
}
private fun onMigrationFinished() {
plugins.filterIsInstance<Callback>().forEach { it.onMigrationFinished() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
MigrationScreenView(
state,
onMigrationFinished = ::onMigrationFinished,
modifier = modifier
)
}
}

View file

@ -67,7 +67,7 @@ fun NotificationsOptInView(
header = { NotificationsOptInHeader(modifier = Modifier.padding(top = 60.dp, bottom = 12.dp)) },
footer = { NotificationsOptInFooter(state) },
) {
NotificationsOptInContent(modifier = Modifier.fillMaxWidth())
NotificationsOptInContent()
}
}
@ -79,7 +79,7 @@ private fun NotificationsOptInHeader(
modifier = modifier,
title = stringResource(R.string.screen_notification_optin_title),
subTitle = stringResource(R.string.screen_notification_optin_subtitle),
iconImageVector = CompoundIcons.NotificationsSolid,
iconImageVector = CompoundIcons.NotificationsSolid(),
)
}
@ -104,10 +104,8 @@ private fun NotificationsOptInFooter(state: NotificationsOptInState) {
}
@Composable
private fun NotificationsOptInContent(
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
private fun NotificationsOptInContent() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
verticalArrangement = Arrangement.spacedBy(
16.dp,
@ -144,10 +142,8 @@ private fun NotificationRow(
avatarColorsId: String,
firstRowPercent: Float,
secondRowPercent: Float,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier,
color = ElementTheme.colors.bgCanvasDisabled,
shape = RoundedCornerShape(14.dp),
shadowElevation = 2.dp,

View file

@ -21,11 +21,9 @@ import android.os.Build
import androidx.annotation.VisibleForTesting
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.ftue.impl.migration.MigrationScreenStore
import io.element.android.features.ftue.impl.welcome.state.WelcomeScreenState
import io.element.android.features.lockscreen.api.LockScreenService
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
@ -43,17 +41,14 @@ class DefaultFtueState @Inject constructor(
coroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
private val welcomeScreenState: WelcomeScreenState,
private val migrationScreenStore: MigrationScreenStore,
private val permissionStateProvider: PermissionStateProvider,
private val lockScreenService: LockScreenService,
private val matrixClient: MatrixClient,
) : FtueState {
override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete())
override suspend fun reset() {
welcomeScreenState.reset()
analyticsService.reset()
migrationScreenStore.reset()
if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
permissionStateProvider.resetPermission(Manifest.permission.POST_NOTIFICATIONS)
}
@ -67,12 +62,7 @@ class DefaultFtueState @Inject constructor(
fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
when (currentStep) {
null -> if (shouldDisplayMigrationScreen()) {
FtueStep.MigrationScreen
} else {
getNextStep(FtueStep.MigrationScreen)
}
FtueStep.MigrationScreen -> if (shouldDisplayWelcomeScreen()) {
null -> if (shouldDisplayWelcomeScreen()) {
FtueStep.WelcomeScreen
} else {
getNextStep(FtueStep.WelcomeScreen)
@ -97,7 +87,6 @@ class DefaultFtueState @Inject constructor(
private fun isAnyStepIncomplete(): Boolean {
return listOf(
{ shouldDisplayMigrationScreen() },
{ shouldDisplayWelcomeScreen() },
{ shouldAskNotificationPermissions() },
{ needsAnalyticsOptIn() },
@ -105,10 +94,6 @@ class DefaultFtueState @Inject constructor(
).any { it() }
}
private fun shouldDisplayMigrationScreen(): Boolean {
return migrationScreenStore.isMigrationScreenNeeded(matrixClient.sessionId)
}
private fun needsAnalyticsOptIn(): Boolean {
// We need this function to not be suspend, so we need to load the value through runBlocking
return runBlocking { analyticsService.didAskUserConsent().first().not() }
@ -147,7 +132,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

@ -43,7 +43,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@ -102,11 +101,11 @@ fun WelcomeView(
private fun listItems() = persistentListOf(
InfoListItem(
message = stringResource(R.string.screen_welcome_bullet_2),
iconId = CommonDrawables.ic_lock_outline,
iconVector = CompoundIcons.Lock(),
),
InfoListItem(
message = stringResource(R.string.screen_welcome_bullet_3),
iconVector = CompoundIcons.ChatProblem,
iconVector = CompoundIcons.ChatProblem(),
),
)

View file

@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Гэта аднаразовы працэс, дзякуем за чаканне."</string>
<string name="screen_migration_title">"Налада ўліковага запісу."</string>
<string name="screen_notification_optin_subtitle">"Вы можаце змяніць налады пазней."</string>
<string name="screen_notification_optin_title">"Дазвольце апавяшчэнні і ніколі не прапускайце іх"</string>
<string name="screen_welcome_bullet_1">"Званкі, апытанні, пошук і многае іншае будзе дададзена пазней у гэтым годзе."</string>

View file

@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<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_notification_optin_subtitle">"Nastavení můžete později změnit."</string>
<string name="screen_notification_optin_title">"Povolte oznámení a nezmeškejte žádnou zprávu"</string>
<string name="screen_welcome_bullet_1">"Hovory, hlasování, vyhledávání a další budou přidány koncem tohoto roku."</string>

View file

@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Dies ist ein einmaliger Vorgang, danke fürs Warten."</string>
<string name="screen_migration_title">"Dein Konto wird eingerichtet."</string>
<string name="screen_notification_optin_subtitle">"Du kannst deine Einstellungen später ändern."</string>
<string name="screen_notification_optin_title">"Erlaube Benachrichtigungen und verpasse keine Nachricht"</string>
<string name="screen_welcome_bullet_1">"Anrufe, Umfragen, Suchfunktionen und mehr werden im Laufe des Jahres hinzugefügt."</string>

View file

@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Este proceso solo se hace una vez, gracias por esperar."</string>
<string name="screen_migration_title">"Configura tu cuenta"</string>
<string name="screen_notification_optin_subtitle">"Puedes cambiar la configuración más tarde."</string>
<string name="screen_notification_optin_title">"Activa las notificaciones y nunca te pierdas un mensaje"</string>
<string name="screen_welcome_bullet_1">"Las llamadas, las encuestas, la búsqueda y más se agregarán más adelante este año."</string>

View file

@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Il sagit dune opération ponctuelle, merci dattendre quelques instants."</string>
<string name="screen_migration_title">"Configuration de votre compte."</string>
<string name="screen_notification_optin_subtitle">"Vous pourrez modifier vos paramètres ultérieurement."</string>
<string name="screen_notification_optin_title">"Autorisez les notifications et ne manquez aucun message"</string>
<string name="screen_welcome_bullet_1">"Les appels, les sondages, les recherches et plus encore seront ajoutés plus tard cette année."</string>

View file

@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Ez egy egyszeri folyamat, köszönjük a türelmét."</string>
<string name="screen_migration_title">"A fiók beállítása."</string>
<string name="screen_notification_optin_subtitle">"A beállításokat később is módosíthatja."</string>
<string name="screen_notification_optin_title">"Értesítések engedélyezése, hogy soha ne maradjon le egyetlen üzenetről sem"</string>
<string name="screen_welcome_bullet_1">"A hívások, szavazások, keresések és egyebek az év további részében kerülnek hozzáadásra."</string>

View file

@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Ini adalah proses satu kali, terima kasih telah menunggu."</string>
<string name="screen_migration_title">"Menyiapkan akun Anda."</string>
<string name="screen_notification_optin_subtitle">"Anda dapat mengubah pengaturan Anda nanti."</string>
<string name="screen_notification_optin_title">"Izinkan pemberitahuan dan jangan pernah melewatkan pesan"</string>
<string name="screen_welcome_bullet_1">"Panggilan, pemungutan suara, pencarian, dan lainnya akan ditambahkan di tahun ini."</string>

View file

@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Si tratta di una procedura che si effettua una sola volta, grazie per l\'attesa."</string>
<string name="screen_migration_title">"Configurazione del tuo account."</string>
<string name="screen_notification_optin_subtitle">"Potrai modificare le tue impostazioni in seguito."</string>
<string name="screen_notification_optin_title">"Consenti le notifiche e non perdere mai un messaggio"</string>
<string name="screen_welcome_bullet_1">"Chiamate, sondaggi, ricerche e altro ancora saranno aggiunti nel corso dell\'anno."</string>

View file

@ -1,7 +1,5 @@
<?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>

View file

@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Это одноразовый процесс, спасибо, что подождали."</string>
<string name="screen_migration_title">"Настройка учетной записи."</string>
<string name="screen_notification_optin_subtitle">"Вы можете изменить настройки позже."</string>
<string name="screen_notification_optin_title">"Разрешите уведомления и никогда не пропустите сообщение"</string>
<string name="screen_welcome_bullet_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string>

View file

@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Ide o jednorazový proces, ďakujeme za trpezlivosť."</string>
<string name="screen_migration_title">"Nastavenie vášho účtu."</string>
<string name="screen_notification_optin_subtitle">"Svoje nastavenia môžete neskôr zmeniť."</string>
<string name="screen_notification_optin_title">"Povoľte oznámenia a nikdy nezmeškajte žiadnu správu"</string>
<string name="screen_welcome_bullet_1">"Hovory, ankety, vyhľadávanie a ďalšie funkcie pribudnú neskôr v tomto roku."</string>

View file

@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"這是一次性的程序,感謝您耐心等候。"</string>
<string name="screen_migration_title">"正在設定您的帳號。"</string>
<string name="screen_welcome_bullet_1">"通話、投票、搜尋等更多功能將在今年登場。"</string>
<string name="screen_welcome_bullet_2">"在這次的更新,您無法查看聊天室內被加密的歷史訊息。"</string>
<string name="screen_welcome_bullet_3">"我們很樂意聽取您的意見,請到設定頁面告訴我們您的想法。"</string>

View file

@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"This is a one time process, thanks for waiting."</string>
<string name="screen_migration_title">"Setting up your account."</string>
<string name="screen_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>

View file

@ -18,16 +18,11 @@ 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
import io.element.android.features.ftue.impl.state.DefaultFtueState
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.test.FakeLockScreenService
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
@ -54,7 +49,6 @@ class DefaultFtueStateTests {
fun `given all checks being true, should display flow is false`() = runTest {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
val lockScreenService = FakeLockScreenService()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
@ -63,14 +57,12 @@ class DefaultFtueStateTests {
coroutineScope = coroutineScope,
welcomeState = welcomeState,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
)
welcomeState.setWelcomeScreenShown()
analyticsService.setDidAskUserConsent()
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true)
state.updateState()
@ -85,7 +77,6 @@ class DefaultFtueStateTests {
fun `traverse flow`() = runTest {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val lockScreenService = FakeLockScreenService()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
@ -94,29 +85,24 @@ class DefaultFtueStateTests {
coroutineScope = coroutineScope,
welcomeState = welcomeState,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
)
val steps = mutableListOf<FtueStep?>()
// First step, migration screen
steps.add(state.getNextStep(steps.lastOrNull()))
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
// Second step, welcome screen
// First step, welcome screen
steps.add(state.getNextStep(steps.lastOrNull()))
welcomeState.setWelcomeScreenShown()
// Third step, notifications opt in
// Second step, notifications opt in
steps.add(state.getNextStep(steps.lastOrNull()))
permissionStateProvider.setPermissionGranted()
// Fourth step, entering PIN code
// Third step, entering PIN code
steps.add(state.getNextStep(steps.lastOrNull()))
lockScreenService.setIsPinSetup(true)
// Fifth step, analytics opt in
// Fourth step, analytics opt in
steps.add(state.getNextStep(steps.lastOrNull()))
analyticsService.setDidAskUserConsent()
@ -124,7 +110,6 @@ class DefaultFtueStateTests {
steps.add(state.getNextStep(steps.lastOrNull()))
assertThat(steps).containsExactly(
FtueStep.MigrationScreen,
FtueStep.WelcomeScreen,
FtueStep.NotificationsOptIn,
FtueStep.LockscreenSetup,
@ -141,19 +126,16 @@ class DefaultFtueStateTests {
fun `if a check for a step is true, start from the next one`() = runTest {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val lockScreenService = FakeLockScreenService()
val state = createState(
coroutineScope = coroutineScope,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
)
// Skip first 4 steps
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
// Skip first 3 steps
state.setWelcomeScreenShown()
permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true)
@ -171,18 +153,15 @@ class DefaultFtueStateTests {
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 lockScreenService = FakeLockScreenService()
val state = createState(
sdkIntVersion = Build.VERSION_CODES.M,
coroutineScope = coroutineScope,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
lockScreenService = lockScreenService,
)
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
assertThat(state.getNextStep()).isEqualTo(FtueStep.WelcomeScreen)
state.setWelcomeScreenShown()
lockScreenService.setIsPinSetup(true)
@ -200,9 +179,7 @@ class DefaultFtueStateTests {
coroutineScope: CoroutineScope,
welcomeState: FakeWelcomeState = FakeWelcomeState(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
migrationScreenStore: MigrationScreenStore = InMemoryMigrationScreenStore(),
permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
matrixClient: MatrixClient = FakeMatrixClient(),
lockScreenService: LockScreenService = FakeLockScreenService(),
// First version where notification permission is required
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
@ -211,9 +188,7 @@ class DefaultFtueStateTests {
coroutineScope = coroutineScope,
analyticsService = analyticsService,
welcomeScreenState = welcomeState,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
matrixClient = matrixClient,
)
}

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_conversation_alert_subtitle">"Bist du sicher, dass du diese Unterhaltung verlassen willst? Diese Unterhaltung ist nicht öffentlich und du kannst ihr ohne Einladung nicht wieder beitreten."</string>
<string name="leave_room_alert_empty_subtitle">"Bist du sicher, dass du diesen Raum verlassen möchtest? Du bist die einzige Person hier. Wenn du austritst, kann in Zukunft niemand mehr eintreten, auch du nicht."</string>
<string name="leave_room_alert_private_subtitle">"Bist du sicher, dass du diesen Raum verlassen möchtest? Dieser Raum ist nicht öffentlich und du kannst ihm ohne Einladung nicht erneut beitreten."</string>
<string name="leave_room_alert_subtitle">"Bist du sicher, dass du den Raum verlassen willst?"</string>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_conversation_alert_subtitle">"Biztos, hogy elhagyja ezt a beszélgetést? Ez a beszélgetés nem nyilvános, és meghívás nélkül nem fog tudni visszacsatlakozni."</string>
<string name="leave_room_alert_empty_subtitle">"Biztos, hogy elhagyja ezt a szobát? Ön az egyedüli ember itt. Ha kilép, akkor senki sem fog tudni csatlakozni a jövőben, Önt is beleértve."</string>
<string name="leave_room_alert_private_subtitle">"Biztos, hogy elhagyod ezt a szobát? Ez a szoba nem nyilvános, és meghívó nélkül nem fogsz tudni újra belépni."</string>
<string name="leave_room_alert_subtitle">"Biztos, hogy elhagyod a szobát?"</string>

View file

@ -39,6 +39,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import com.mapbox.mapboxsdk.camera.CameraPosition
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.internal.centerBottomEdge
import io.element.android.features.location.api.internal.rememberTileStyleUrl
@ -211,8 +212,8 @@ fun SendLocationView(
.padding(end = 16.dp, bottom = 72.dp + navBarPadding),
) {
when (state.mode) {
SendLocationState.Mode.PinLocation -> Icon(resourceId = CommonDrawables.ic_location_navigator, contentDescription = null)
SendLocationState.Mode.SenderLocation -> Icon(resourceId = CommonDrawables.ic_location_navigator_centered, contentDescription = null)
SendLocationState.Mode.PinLocation -> Icon(imageVector = CompoundIcons.LocationNavigator(), contentDescription = null)
SendLocationState.Mode.SenderLocation -> Icon(imageVector = CompoundIcons.LocationNavigatorCentred(), contentDescription = null)
}
}
}

View file

@ -125,7 +125,7 @@ fun ShowLocationView(
actions = {
IconButton(onClick = { state.eventSink(ShowLocationEvents.Share) }) {
Icon(
imageVector = CompoundIcons.ShareAndroid,
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = stringResource(CommonStrings.action_share),
)
}

View file

@ -66,10 +66,8 @@ fun PinEntryTextField(
private fun PinEntryRow(
pinEntry: PinEntry,
isSecured: Boolean,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
@ -83,7 +81,6 @@ private fun PinEntryRow(
private fun PinDigitView(
digit: PinDigit,
isSecured: Boolean,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(8.dp)
val appearanceModifier = when (digit) {
@ -95,7 +92,7 @@ private fun PinDigitView(
}
}
Box(
modifier = modifier
modifier = Modifier
.size(48.dp)
.then(appearanceModifier),
contentAlignment = Alignment.Center,

View file

@ -57,13 +57,12 @@ fun SetupBiometricView(
}
@Composable
private fun SetupBiometricHeader(modifier: Modifier = Modifier) {
private fun SetupBiometricHeader() {
val biometricAuth = stringResource(id = R.string.screen_app_lock_biometric_authentication)
IconTitleSubtitleMolecule(
iconImageVector = Icons.Default.Fingerprint,
title = stringResource(id = R.string.screen_app_lock_settings_enable_biometric_unlock),
subTitle = stringResource(id = R.string.screen_app_lock_setup_biometric_unlock_subtitle, biometricAuth),
modifier = modifier
)
}
@ -71,11 +70,8 @@ private fun SetupBiometricHeader(modifier: Modifier = Modifier) {
private fun SetupBiometricFooter(
onAllowClicked: () -> Unit,
onSkipClicked: () -> Unit,
modifier: Modifier = Modifier
) {
ButtonColumnMolecule(
modifier = modifier,
) {
ButtonColumnMolecule {
val biometricAuth = stringResource(id = R.string.screen_app_lock_biometric_authentication)
Button(
text = stringResource(id = R.string.screen_app_lock_setup_biometric_unlock_allow_title, biometricAuth),

View file

@ -86,10 +86,8 @@ fun SetupPinView(
private fun SetupPinHeader(
isValidationStep: Boolean,
appName: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
IconTitleSubtitleMolecule(
@ -107,7 +105,6 @@ private fun SetupPinHeader(
@Composable
private fun SetupPinContent(
state: SetupPinState,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
@ -119,14 +116,13 @@ private fun SetupPinContent(
onValueChange = { entry ->
state.eventSink(SetupPinEvents.OnPinEntryChanged(entry, state.isConfirmationStep))
},
modifier = modifier
modifier = Modifier
.focusRequester(focusRequester)
.padding(top = 36.dp)
.fillMaxWidth()
)
if (state.setupPinFailure != null) {
ErrorDialog(
modifier = modifier,
title = state.setupPinFailure.title(),
content = state.setupPinFailure.content(),
onDismiss = {

View file

@ -107,10 +107,9 @@ fun PinUnlockView(
private fun PinUnlockPage(
state: PinUnlockState,
isInAppUnlock: Boolean,
modifier: Modifier = Modifier
) {
BoxWithConstraints {
val commonModifier = modifier
val commonModifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding()
@ -188,7 +187,6 @@ private fun SignOutPrompt(
isCancellable: Boolean,
onSignOut: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
if (isCancellable) {
ConfirmationDialog(
@ -196,14 +194,12 @@ private fun SignOutPrompt(
content = stringResource(id = R.string.screen_app_lock_signout_alert_message),
onSubmitClicked = onSignOut,
onDismiss = onDismiss,
modifier = modifier,
)
} else {
ErrorDialog(
title = stringResource(id = R.string.screen_app_lock_signout_alert_title),
content = stringResource(id = R.string.screen_app_lock_signout_alert_message),
onDismiss = onSignOut,
modifier = modifier,
)
}
}
@ -258,9 +254,11 @@ private fun PinUnlockExpandedView(
@Composable
private fun PinDotsRow(
pinEntry: PinEntry,
modifier: Modifier = Modifier,
) {
Row(modifier, horizontalArrangement = spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Row(
horizontalArrangement = spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
for (digit in pinEntry.digits) {
PinDot(isFilled = digit is PinDigit.Filled)
}
@ -270,7 +268,6 @@ private fun PinDotsRow(
@Composable
private fun PinDot(
isFilled: Boolean,
modifier: Modifier = Modifier,
) {
val backgroundColor = if (isFilled) {
ElementTheme.colors.iconPrimary
@ -278,7 +275,7 @@ private fun PinDot(
ElementTheme.colors.bgSubtlePrimary
}
Box(
modifier = modifier
modifier = Modifier
.size(14.dp)
.background(backgroundColor, CircleShape)
)
@ -290,7 +287,10 @@ private fun PinUnlockHeader(
isInAppUnlock: Boolean,
modifier: Modifier = Modifier,
) {
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (isInAppUnlock) {
RoundedIconAtom(imageVector = Icons.Filled.Lock)
} else {

View file

@ -108,14 +108,13 @@ private fun PinKeypadRow(
models: ImmutableList<PinKeypadModel>,
onClick: (PinKeypadModel) -> Unit,
pinKeySize: Dp,
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
verticalAlignment: Alignment.Vertical = Alignment.Top,
) {
Row(
horizontalArrangement = horizontalArrangement,
verticalAlignment = verticalAlignment,
modifier = modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(),
) {
val commonModifier = Modifier.size(pinKeySize)
for (model in models) {

View file

@ -76,7 +76,7 @@ fun AccountProviderView(
} else {
RoundedIconAtom(
size = RoundedIconAtomSize.Medium,
imageVector = CompoundIcons.Search,
imageVector = CompoundIcons.Search(),
tint = MaterialTheme.colorScheme.primary,
)
}

View file

@ -179,7 +179,6 @@ private fun LoginForm(
state: LoginPasswordState,
isLoading: Boolean,
onSubmit: () -> Unit,
modifier: Modifier = Modifier
) {
var loginFieldState by textFieldState(stateValue = state.formState.login)
var passwordFieldState by textFieldState(stateValue = state.formState.password)
@ -187,7 +186,7 @@ private fun LoginForm(
val focusManager = LocalFocusManager.current
val eventSink = state.eventSink
Column(modifier) {
Column {
Text(
text = stringResource(R.string.screen_login_form_header),
modifier = Modifier.padding(start = 16.dp),
@ -228,7 +227,7 @@ private fun LoginForm(
IconButton(onClick = {
loginFieldState = ""
}) {
Icon(imageVector = CompoundIcons.Close, contentDescription = stringResource(CommonStrings.action_clear))
Icon(imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_clear))
}
}
} else {
@ -264,7 +263,7 @@ private fun LoginForm(
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
val image =
if (passwordVisible) CompoundIcons.VisibilityOn else CompoundIcons.VisibilityOff
if (passwordVisible) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff()
val description =
if (passwordVisible) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password)

View file

@ -103,7 +103,7 @@ fun SearchAccountProviderView(
item {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 16.dp, bottom = 40.dp, start = 16.dp, end = 16.dp),
iconImageVector = CompoundIcons.Search,
iconImageVector = CompoundIcons.Search(),
title = stringResource(id = R.string.screen_account_provider_form_title),
subTitle = stringResource(id = R.string.screen_account_provider_form_subtitle),
)
@ -139,7 +139,7 @@ fun SearchAccountProviderView(
eventSink(SearchAccountProviderEvents.UserInput(""))
}) {
Icon(
imageVector = CompoundIcons.Close,
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_clear)
)
}

View file

@ -25,11 +25,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.error.isWaitListError
import io.element.android.features.login.impl.error.loginError
@ -119,11 +119,10 @@ private fun WaitListContent(
private fun OverallContent(
state: WaitListState,
onCancelClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize()) {
if (state.loginAction !is AsyncData.Success) {
CompositionLocalProvider(LocalContentColor provides Color.Black) {
CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textOnSolidPrimary) {
TextButton(
text = stringResource(CommonStrings.action_cancel),
onClick = onCancelClicked,

View file

@ -24,12 +24,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.logout.impl.tools.isBackingUp
import io.element.android.features.logout.impl.ui.LogoutActionDialog
import io.element.android.libraries.architecture.AsyncAction
@ -41,7 +40,6 @@ import io.element.android.libraries.designsystem.theme.components.LinearProgress
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.RecoveryState
@ -64,7 +62,7 @@ fun LogoutView(
onBackClicked = onBackClicked,
title = title(state),
subTitle = subtitle(state),
iconVector = ImageVector.vectorResource(CommonDrawables.ic_key),
iconVector = CompoundIcons.KeySolid(),
modifier = modifier,
content = { Content(state) },
buttons = {
@ -86,7 +84,7 @@ fun LogoutView(
onForceLogoutClicked = {
eventSink(LogoutEvents.Logout(ignoreSdkError = true))
},
onDismissError = {
onDismissDialog = {
eventSink(LogoutEvents.CloseDialogs)
},
onSuccessLogout = {

View file

@ -41,7 +41,7 @@ class DefaultDirectLogoutView @Inject constructor() : DirectLogoutView {
onForceLogoutClicked = {
eventSink(DirectLogoutEvents.Logout(ignoreSdkError = true))
},
onDismissError = {
onDismissDialog = {
eventSink(DirectLogoutEvents.CloseDialogs)
},
onSuccessLogout = {

View file

@ -32,8 +32,7 @@ fun LogoutActionDialog(
state: AsyncAction<String?>,
onConfirmClicked: () -> Unit,
onForceLogoutClicked: () -> Unit,
// TODO Rename
onDismissError: () -> Unit,
onDismissDialog: () -> Unit,
onSuccessLogout: (String?) -> Unit,
) {
when (state) {
@ -42,7 +41,7 @@ fun LogoutActionDialog(
AsyncAction.Confirming ->
LogoutConfirmationDialog(
onSubmitClicked = onConfirmClicked,
onDismiss = onDismissError
onDismiss = onDismissDialog
)
is AsyncAction.Loading ->
ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
@ -52,7 +51,7 @@ fun LogoutActionDialog(
content = stringResource(id = CommonStrings.error_unknown),
retryText = stringResource(id = CommonStrings.action_signout_anyway),
onRetry = onForceLogoutClicked,
onDismiss = onDismissError,
onDismiss = onDismissDialog,
)
is AsyncAction.Success -> {
val latestOnSuccessLogout by rememberUpdatedState(onSuccessLogout)

View file

@ -62,6 +62,7 @@ dependencies {
implementation(projects.libraries.voicerecorder.api)
implementation(projects.libraries.mediaplayer.api)
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.testtags)
implementation(projects.features.networkmonitor.api)
implementation(projects.services.analytics.api)
implementation(libs.coil.compose)

View file

@ -59,6 +59,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.typing.TypingNotificationPresenter
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.networkmonitor.api.NetworkMonitor
@ -97,6 +98,7 @@ class MessagesPresenter @AssistedInject constructor(
private val composerPresenter: MessageComposerPresenter,
private val voiceMessageComposerPresenter: VoiceMessageComposerPresenter,
timelinePresenterFactory: TimelinePresenter.Factory,
private val typingNotificationPresenter: TypingNotificationPresenter,
private val actionListPresenter: ActionListPresenter,
private val customReactionPresenter: CustomReactionPresenter,
private val reactionSummaryPresenter: ReactionSummaryPresenter,
@ -129,6 +131,7 @@ class MessagesPresenter @AssistedInject constructor(
val composerState = composerPresenter.present()
val voiceMessageComposerState = voiceMessageComposerPresenter.present()
val timelineState = timelinePresenter.present()
val typingNotificationState = typingNotificationPresenter.present()
val actionListState = actionListPresenter.present()
val customReactionState = customReactionPresenter.present()
val reactionSummaryState = reactionSummaryPresenter.present()
@ -155,6 +158,14 @@ class MessagesPresenter @AssistedInject constructor(
mutableStateOf(false)
}
LaunchedEffect(Unit) {
// Remove the unread flag on entering but don't send read receipts
// as those will be handled by the timeline.
withContext(dispatchers.io) {
room.setUnreadFlag(isUnread = false)
}
}
LaunchedEffect(syncUpdateFlow.value) {
withContext(dispatchers.io) {
canJoinCall = room.canUserJoinCall(room.sessionId).getOrDefault(false)
@ -225,6 +236,7 @@ class MessagesPresenter @AssistedInject constructor(
composerState = composerState,
voiceMessageComposerState = voiceMessageComposerState,
timelineState = timelineState,
typingNotificationState = typingNotificationState,
actionListState = actionListState,
customReactionState = customReactionState,
reactionSummaryState = reactionSummaryState,

View file

@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.timeline.components.customreact
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@ -42,6 +43,7 @@ data class MessagesState(
val composerState: MessageComposerState,
val voiceMessageComposerState: VoiceMessageComposerState,
val timelineState: TimelineState,
val typingNotificationState: TypingNotificationState,
val actionListState: ActionListState,
val customReactionState: CustomReactionState,
val reactionSummaryState: ReactionSummaryState,

View file

@ -17,16 +17,26 @@
package io.element.android.features.messages.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.aRetrySendMenuState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState
import io.element.android.libraries.architecture.AsyncData
@ -42,87 +52,120 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
override val values: Sequence<MessagesState>
get() = sequenceOf(
aMessagesState(),
aMessagesState().copy(hasNetworkConnection = false),
aMessagesState().copy(composerState = aMessageComposerState().copy(showAttachmentSourcePicker = true)),
aMessagesState().copy(userHasPermissionToSendMessage = false),
aMessagesState().copy(showReinvitePrompt = true),
aMessagesState().copy(
aMessagesState(hasNetworkConnection = false),
aMessagesState(composerState = aMessageComposerState(showAttachmentSourcePicker = true)),
aMessagesState(userHasPermissionToSendMessage = false),
aMessagesState(showReinvitePrompt = true),
aMessagesState(
roomName = AsyncData.Uninitialized,
roomAvatar = AsyncData.Uninitialized,
),
aMessagesState().copy(composerState = aMessageComposerState().copy(showTextFormatting = true)),
aMessagesState().copy(
aMessagesState(composerState = aMessageComposerState(showTextFormatting = true)),
aMessagesState(
enableVoiceMessages = true,
voiceMessageComposerState = aVoiceMessageComposerState(showPermissionRationaleDialog = true),
),
aMessagesState().copy(
composerState = aMessageComposerState().copy(
aMessagesState(
composerState = aMessageComposerState(
attachmentsState = AttachmentsState.Sending.Processing(persistentListOf())
),
),
aMessagesState().copy(
composerState = aMessageComposerState().copy(
aMessagesState(
composerState = aMessageComposerState(
attachmentsState = AttachmentsState.Sending.Uploading(0.33f)
),
),
aMessagesState().copy(
aMessagesState(
callState = RoomCallState.ONGOING,
),
aMessagesState().copy(
aMessagesState(
enableVoiceMessages = true,
voiceMessageComposerState = aVoiceMessageComposerState(
voiceMessageState = aVoiceMessagePreviewState(),
showSendFailureDialog = true
),
),
aMessagesState().copy(
aMessagesState(
callState = RoomCallState.DISABLED,
),
)
}
fun aMessagesState() = MessagesState(
roomId = RoomId("!id:domain"),
roomName = AsyncData.Success("Room name"),
roomAvatar = AsyncData.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)),
userHasPermissionToSendMessage = true,
userHasPermissionToRedactOwn = false,
userHasPermissionToRedactOther = false,
userHasPermissionToSendReaction = true,
composerState = aMessageComposerState().copy(
fun aMessagesState(
roomName: AsyncData<String> = AsyncData.Success("Room name"),
roomAvatar: AsyncData<AvatarData> = AsyncData.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)),
userHasPermissionToSendMessage: Boolean = true,
userHasPermissionToRedactOwn: Boolean = false,
userHasPermissionToRedactOther: Boolean = false,
userHasPermissionToSendReaction: Boolean = true,
composerState: MessageComposerState = aMessageComposerState(
richTextEditorState = RichTextEditorState("Hello", initialFocus = true),
isFullScreen = false,
mode = MessageComposerMode.Normal,
),
voiceMessageComposerState = aVoiceMessageComposerState(),
timelineState = aTimelineState().copy(
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
timelineState: TimelineState = aTimelineState(
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
),
retrySendMenuState = RetrySendMenuState(
selectedEvent = null,
eventSink = {},
),
readReceiptBottomSheetState = ReadReceiptBottomSheetState(
selectedEvent = null,
eventSink = {},
),
actionListState = anActionListState(),
customReactionState = CustomReactionState(
target = CustomReactionState.Target.None,
eventSink = {},
selectedEmoji = persistentSetOf(),
),
reactionSummaryState = ReactionSummaryState(
target = null,
eventSink = {},
),
hasNetworkConnection = true,
retrySendMenuState: RetrySendMenuState = aRetrySendMenuState(),
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
actionListState: ActionListState = anActionListState(),
customReactionState: CustomReactionState = aCustomReactionState(),
reactionSummaryState: ReactionSummaryState = aReactionSummaryState(),
hasNetworkConnection: Boolean = true,
showReinvitePrompt: Boolean = false,
enableVoiceMessages: Boolean = true,
callState: RoomCallState = RoomCallState.ENABLED,
eventSink: (MessagesEvents) -> Unit = {},
) = MessagesState(
roomId = RoomId("!id:domain"),
roomName = roomName,
roomAvatar = roomAvatar,
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToRedactOwn = userHasPermissionToRedactOwn,
userHasPermissionToRedactOther = userHasPermissionToRedactOther,
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
composerState = composerState,
voiceMessageComposerState = voiceMessageComposerState,
typingNotificationState = aTypingNotificationState(),
timelineState = timelineState,
retrySendMenuState = retrySendMenuState,
readReceiptBottomSheetState = readReceiptBottomSheetState,
actionListState = actionListState,
customReactionState = customReactionState,
reactionSummaryState = reactionSummaryState,
hasNetworkConnection = hasNetworkConnection,
snackbarMessage = null,
inviteProgress = AsyncData.Uninitialized,
showReinvitePrompt = false,
showReinvitePrompt = showReinvitePrompt,
enableTextFormatting = true,
enableVoiceMessages = true,
callState = RoomCallState.ENABLED,
enableVoiceMessages = enableVoiceMessages,
callState = callState,
appName = "Element",
eventSink = {}
eventSink = eventSink,
)
fun aReactionSummaryState(
target: ReactionSummaryState.Summary? = null,
eventSink: (ReactionSummaryEvents) -> Unit = {}
) = ReactionSummaryState(
target = target,
eventSink = eventSink,
)
fun aCustomReactionState(
target: CustomReactionState.Target = CustomReactionState.Target.None,
eventSink: (CustomReactionEvents) -> Unit = {},
) = CustomReactionState(
target = target,
selectedEmoji = persistentSetOf(),
eventSink = eventSink,
)
fun aReadReceiptBottomSheetState(
selectedEvent: TimelineItem.Event? = null,
eventSink: (ReadReceiptBottomSheetEvents) -> Unit = {},
) = ReadReceiptBottomSheetState(
selectedEvent = selectedEvent,
eventSink = eventSink,
)

View file

@ -39,7 +39,6 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@ -102,7 +101,6 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.KeepScreenOn
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
@ -126,9 +124,8 @@ fun MessagesView(
onCreatePollClicked: () -> Unit,
onJoinCallClicked: () -> Unit,
modifier: Modifier = Modifier,
forceJumpToBottomVisibility: Boolean = false
) {
LogCompositions(tag = "MessagesScreen", msg = "Root")
OnLifecycleEvent { _, event ->
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event))
}
@ -146,8 +143,6 @@ fun MessagesView(
// This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
val localView = LocalView.current
LogCompositions(tag = "MessagesScreen", msg = "Content")
fun onMessageClicked(event: TimelineItem.Event) {
Timber.v("OnMessageClicked= ${event.id}")
val hideKeyboard = onEventClicked(event)
@ -198,7 +193,12 @@ fun MessagesView(
roomName = state.roomName.dataOrNull(),
roomAvatar = state.roomAvatar.dataOrNull(),
callState = state.callState,
onBackPressed = onBackPressed,
onBackPressed = {
// Since the textfield is now based on an Android view, this is no longer done automatically.
// We need to hide the keyboard when navigating out of this screen.
localView.hideKeyboard()
onBackPressed()
},
onRoomDetailsClicked = onRoomDetailsClicked,
onJoinCallClicked = onJoinCallClicked,
)
@ -229,6 +229,7 @@ fun MessagesView(
onSwipeToReply = { targetEvent ->
state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, targetEvent))
},
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
)
},
snackbarHost = {
@ -253,7 +254,6 @@ fun MessagesView(
state = state.customReactionState,
onEmojiSelected = { eventId, emoji ->
state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId))
state.customReactionState.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
}
)
@ -264,14 +264,6 @@ fun MessagesView(
onUserDataClicked = onUserDataClicked,
)
ReinviteDialog(state = state)
// Since the textfield is now based on an Android view, this is no longer done automatically.
// We need to hide the keyboard automatically when navigating out of this screen.
DisposableEffect(Unit) {
onDispose {
localView.hideKeyboard()
}
}
}
@Composable
@ -329,6 +321,7 @@ private fun MessagesViewContent(
onTimestampClicked: (TimelineItem.Event) -> Unit,
onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit,
forceJumpToBottomVisibility: Boolean,
modifier: Modifier = Modifier,
onSwipeToReply: (TimelineItem.Event) -> Unit,
) {
@ -389,6 +382,7 @@ private fun MessagesViewContent(
modifier = Modifier.padding(paddingValues),
state = state.timelineState,
roomName = state.roomName.dataOrNull(),
typingNotificationState = state.typingNotificationState,
onMessageClicked = onMessageClicked,
onMessageLongClicked = onMessageLongClicked,
onUserDataClicked = onUserDataClicked,
@ -398,6 +392,7 @@ private fun MessagesViewContent(
onMoreReactionsClicked = onMoreReactionsClicked,
onReadReceiptClick = onReadReceiptClick,
onSwipeToReply = onSwipeToReply,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
)
},
sheetContent = { subcomposing: Boolean ->
@ -417,10 +412,9 @@ private fun MessagesViewContent(
private fun MessagesViewComposerBottomSheetContents(
subcomposing: Boolean,
state: MessagesState,
modifier: Modifier = Modifier,
) {
if (state.userHasPermissionToSendMessage) {
Column(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth()) {
MentionSuggestionsPickerView(
modifier = Modifier
.heightIn(max = 230.dp)
@ -448,7 +442,7 @@ private fun MessagesViewComposerBottomSheetContents(
)
}
} else {
CantSendMessageBanner(modifier = modifier)
CantSendMessageBanner()
}
}
@ -461,10 +455,8 @@ private fun MessagesViewTopBar(
onRoomDetailsClicked: () -> Unit,
onJoinCallClicked: () -> Unit,
onBackPressed: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
modifier = modifier,
navigationIcon = {
BackButton(onClick = onBackPressed)
},
@ -489,7 +481,7 @@ private fun MessagesViewTopBar(
} else {
IconButton(onClick = onJoinCallClicked, enabled = callState != RoomCallState.DISABLED) {
Icon(
imageVector = CompoundIcons.VideoCallSolid,
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = stringResource(CommonStrings.a11y_start_call),
)
}
@ -502,7 +494,6 @@ private fun MessagesViewTopBar(
@Composable
private fun JoinCallMenuItem(
modifier: Modifier = Modifier,
onJoinCallClicked: () -> Unit,
) {
Material3Button(
@ -512,11 +503,11 @@ private fun JoinCallMenuItem(
containerColor = ElementTheme.colors.iconAccentTertiary
),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
modifier = modifier.heightIn(min = 36.dp),
modifier = Modifier.heightIn(min = 36.dp),
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = CompoundIcons.VideoCallSolid,
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = null
)
Spacer(Modifier.width(8.dp))
@ -550,11 +541,9 @@ private fun RoomAvatarAndNameRow(
}
@Composable
private fun CantSendMessageBanner(
modifier: Modifier = Modifier,
) {
private fun CantSendMessageBanner() {
Row(
modifier = modifier
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.secondary)
.padding(16.dp),
@ -584,5 +573,6 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onSendLocationClicked = {},
onCreatePollClicked = {},
onJoinCallClicked = {},
forceJumpToBottomVisibility = true,
)
}

View file

@ -122,9 +122,12 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
}
}
fun anActionListState() = ActionListState(
target = ActionListState.Target.None,
eventSink = {}
fun anActionListState(
target: ActionListState.Target = ActionListState.Target.None,
eventSink: (ActionListEvents) -> Unit = {},
) = ActionListState(
target = target,
eventSink = eventSink
)
fun aTimelineItemActionList(): ImmutableList<TimelineItemAction> {

View file

@ -53,6 +53,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
@ -83,7 +84,6 @@ import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.hide
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@ -316,7 +316,7 @@ private fun EmojiReactionsRow(
contentAlignment = Alignment.Center
) {
Icon(
resourceId = CommonDrawables.ic_add_reaction,
imageVector = CompoundIcons.ReactionAdd(),
contentDescription = stringResource(id = CommonStrings.a11y_react_with_other_emojis),
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier
@ -337,7 +337,6 @@ private fun EmojiButton(
emoji: String,
isHighlighted: Boolean,
onClicked: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val backgroundColor = if (isHighlighted) {
ElementTheme.colors.bgActionPrimaryRest
@ -350,7 +349,7 @@ private fun EmojiButton(
stringResource(id = CommonStrings.a11y_react_with, emoji)
}
Box(
modifier = modifier
modifier = Modifier
.size(48.dp)
.background(backgroundColor, CircleShape)
.clearAndSetSemantics {

View file

@ -29,13 +29,13 @@ sealed class TimelineItemAction(
@DrawableRes val icon: Int,
val destructive: Boolean = false
) {
data object Forward : TimelineItemAction(CommonStrings.action_forward, CommonDrawables.ic_forward)
data object Copy : TimelineItemAction(CommonStrings.action_copy, CommonDrawables.ic_copy)
data object Redact : TimelineItemAction(CommonStrings.action_remove, CompoundDrawables.ic_delete, destructive = true)
data object Reply : TimelineItemAction(CommonStrings.action_reply, CommonDrawables.ic_reply)
data object ReplyInThread : TimelineItemAction(CommonStrings.action_reply_in_thread, CommonDrawables.ic_reply)
data object Edit : TimelineItemAction(CommonStrings.action_edit, CommonDrawables.ic_edit_outline)
data object Forward : TimelineItemAction(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward)
data object Copy : TimelineItemAction(CommonStrings.action_copy, CompoundDrawables.ic_compound_copy)
data object Redact : TimelineItemAction(CommonStrings.action_remove, CompoundDrawables.ic_compound_delete, destructive = true)
data object Reply : TimelineItemAction(CommonStrings.action_reply, CompoundDrawables.ic_compound_reply)
data object ReplyInThread : TimelineItemAction(CommonStrings.action_reply_in_thread, CompoundDrawables.ic_compound_reply)
data object Edit : TimelineItemAction(CommonStrings.action_edit, CompoundDrawables.ic_compound_edit)
data object ViewSource : TimelineItemAction(CommonStrings.action_view_source, CommonDrawables.ic_developer_options)
data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, CompoundDrawables.ic_chat_problem, destructive = true)
data object EndPoll : TimelineItemAction(CommonStrings.action_end_poll, CompoundDrawables.ic_polls_end)
data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, CompoundDrawables.ic_compound_chat_problem, destructive = true)
data object EndPoll : TimelineItemAction(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end)
}

View file

@ -118,10 +118,9 @@ private fun AttachmentPreviewContent(
attachment: Attachment,
onSendClicked: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
modifier = Modifier
.fillMaxSize()
.padding(top = 24.dp)
) {

View file

@ -43,7 +43,6 @@ import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -101,34 +100,33 @@ private fun AttachmentSourcePickerMenu(
onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit,
enableTextFormatting: Boolean,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
modifier = Modifier
.navigationBarsPadding()
.imePadding()
) {
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_image)),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
style = ListItemStyle.Primary,
)
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_attachment)),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Attachment())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
style = ListItemStyle.Primary,
)
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_take_photo_camera)),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.TakePhoto())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
style = ListItemStyle.Primary,
)
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.VideoCall)),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.VideoCall())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
style = ListItemStyle.Primary,
)
@ -138,7 +136,7 @@ private fun AttachmentSourcePickerMenu(
state.eventSink(MessageComposerEvents.PickAttachmentSource.Location)
onSendLocationClicked()
},
leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_location_pin)),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
style = ListItemStyle.Primary,
)
@ -149,7 +147,7 @@ private fun AttachmentSourcePickerMenu(
state.eventSink(MessageComposerEvents.PickAttachmentSource.Poll)
onCreatePollClicked()
},
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Polls)),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Polls())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
style = ListItemStyle.Primary,
)
@ -157,7 +155,7 @@ private fun AttachmentSourcePickerMenu(
if (enableTextFormatting) {
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.ToggleTextFormatting(enabled = true)) },
leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_text_formatting, null)),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.TextFormatting())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_text_formatting)) },
style = ListItemStyle.Primary,
)

View file

@ -32,7 +32,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
}
fun aMessageComposerState(
composerState: RichTextEditorState = RichTextEditorState(""),
richTextEditorState: RichTextEditorState = RichTextEditorState(""),
isFullScreen: Boolean = false,
mode: MessageComposerMode = MessageComposerMode.Normal,
showTextFormatting: Boolean = false,
@ -42,7 +42,7 @@ fun aMessageComposerState(
attachmentsState: AttachmentsState = AttachmentsState.None,
memberSuggestions: ImmutableList<MentionSuggestion> = persistentListOf(),
) = MessageComposerState(
richTextEditorState = composerState,
richTextEditorState = richTextEditorState,
isFullScreen = isFullScreen,
mode = mode,
showTextFormatting = showTextFormatting,

View file

@ -22,7 +22,6 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
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
@ -90,7 +89,6 @@ class TimelinePresenter @AssistedInject constructor(
mutableStateOf(null)
}
val lastReadReceiptIndex = rememberSaveable { mutableIntStateOf(Int.MAX_VALUE) }
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
val timelineItems by timelineItemsFactory.collectItemsAsState()
@ -128,7 +126,6 @@ class TimelinePresenter @AssistedInject constructor(
appScope.sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex,
timelineItems = timelineItems,
lastReadReceiptIndex = lastReadReceiptIndex,
lastReadReceiptId = lastReadReceiptId,
readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE,
)
@ -228,16 +225,19 @@ class TimelinePresenter @AssistedInject constructor(
private fun CoroutineScope.sendReadReceiptIfNeeded(
firstVisibleIndex: Int,
timelineItems: ImmutableList<TimelineItem>,
lastReadReceiptIndex: MutableState<Int>,
lastReadReceiptId: MutableState<EventId?>,
readReceiptType: ReceiptType,
) = launch(dispatchers.computation) {
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)
if (eventId != null && firstVisibleIndex <= lastReadReceiptIndex.value && eventId != lastReadReceiptId.value) {
lastReadReceiptIndex.value = firstVisibleIndex
lastReadReceiptId.value = eventId
timeline.sendReadReceipt(eventId = eventId, receiptType = readReceiptType)
// If we are at the bottom of timeline, we mark the room as read.
if (firstVisibleIndex == 0) {
room.markAsRead(receiptType = readReceiptType)
} else {
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)
if (eventId != null && eventId != lastReadReceiptId.value) {
lastReadReceiptId.value = eventId
timeline.sendReadReceipt(eventId = eventId, receiptType = readReceiptType)
}
}
}

View file

@ -48,12 +48,14 @@ import kotlin.random.Random
fun aTimelineState(
timelineItems: ImmutableList<TimelineItem> = persistentListOf(),
paginationState: MatrixTimeline.PaginationState = aPaginationState(),
renderReadReceipts: Boolean = false,
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
eventSink: (TimelineEvents) -> Unit = {},
) = TimelineState(
timelineItems = timelineItems,
timelineRoomInfo = aTimelineRoomInfo(),
timelineRoomInfo = timelineRoomInfo,
paginationState = paginationState,
renderReadReceipts = false,
renderReadReceipts = renderReadReceipts,
highlightedEventId = null,
newEventState = NewEventState.None,
sessionState = aSessionState(
@ -196,9 +198,11 @@ internal fun aTimelineItemDebugInfo(
latestEditedJson
)
internal fun aTimelineItemReadReceipts(): TimelineItemReadReceipts {
internal fun aTimelineItemReadReceipts(
receipts: List<ReadReceiptData> = emptyList(),
): TimelineItemReadReceipts {
return TimelineItemReadReceipts(
receipts = emptyList<ReadReceiptData>().toImmutableList(),
receipts = receipts.toImmutableList(),
)
}
@ -232,8 +236,9 @@ internal fun aGroupedEvents(
internal fun aTimelineRoomInfo(
isDirect: Boolean = false,
userHasPermissionToSendMessage: Boolean = true,
) = TimelineRoomInfo(
isDirect = isDirect,
userHasPermissionToSendMessage = true,
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToSendReaction = true,
)

View file

@ -47,7 +47,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@ -62,6 +61,9 @@ import io.element.android.features.messages.impl.timeline.model.NewEventState
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.typing.TypingNotificationState
import io.element.android.features.messages.impl.typing.TypingNotificationView
import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.libraries.designsystem.animation.alphaAnimation
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -75,6 +77,7 @@ import kotlinx.coroutines.launch
@Composable
fun TimelineView(
state: TimelineState,
typingNotificationState: TypingNotificationState,
roomName: String?,
onUserDataClicked: (UserId) -> Unit,
onMessageClicked: (TimelineItem.Event) -> Unit,
@ -86,6 +89,7 @@ fun TimelineView(
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
forceJumpToBottomVisibility: Boolean = false
) {
fun onReachedLoadMore() {
state.eventSink(TimelineEvents.LoadMore)
@ -112,6 +116,9 @@ fun TimelineView(
reverseLayout = true,
contentPadding = PaddingValues(vertical = 8.dp),
) {
item {
TypingNotificationView(state = typingNotificationState)
}
items(
items = state.timelineItems,
contentType = { timelineItem -> timelineItem.contentType() },
@ -157,6 +164,7 @@ fun TimelineView(
TimelineScrollHelper(
isTimelineEmpty = state.timelineItems.isEmpty(),
lazyListState = lazyListState,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
newEventState = state.newEventState,
onScrollFinishedAt = ::onScrollFinishedAt
)
@ -168,6 +176,7 @@ private fun BoxScope.TimelineScrollHelper(
isTimelineEmpty: Boolean,
lazyListState: LazyListState,
newEventState: NewEventState,
forceJumpToBottomVisibility: Boolean,
onScrollFinishedAt: (Int) -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
@ -205,7 +214,7 @@ private fun BoxScope.TimelineScrollHelper(
JumpToBottomButton(
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
isVisible = !canAutoScroll,
isVisible = !canAutoScroll || forceJumpToBottomVisibility,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 12.dp),
@ -221,7 +230,7 @@ private fun JumpToBottomButton(
) {
AnimatedVisibility(
modifier = modifier,
visible = isVisible || LocalInspectionMode.current,
visible = isVisible,
enter = scaleIn(animationSpec = tween(100)),
exit = scaleOut(animationSpec = tween(100)),
) {
@ -237,7 +246,7 @@ private fun JumpToBottomButton(
modifier = Modifier
.size(24.dp)
.rotate(90f),
imageVector = CompoundIcons.ArrowRight,
imageVector = CompoundIcons.ArrowRight(),
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom)
)
}
@ -256,6 +265,7 @@ internal fun TimelineViewPreview(
TimelineView(
state = aTimelineState(timelineItems),
roomName = null,
typingNotificationState = aTypingNotificationState(),
onMessageClicked = {},
onTimestampClicked = {},
onUserDataClicked = {},
@ -265,6 +275,7 @@ internal fun TimelineViewPreview(
onMoreReactionsClicked = {},
onSwipeToReply = {},
onReadReceiptClick = {},
forceJumpToBottomVisibility = true,
)
}
}

View file

@ -46,6 +46,8 @@ import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.messageFromMeBackground
import io.element.android.libraries.designsystem.theme.messageFromOtherBackground
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
private val BUBBLE_RADIUS = 12.dp
internal val BUBBLE_INCOMING_OFFSET = 16.dp
@ -115,6 +117,7 @@ fun MessageEventBubble(
) {
Surface(
modifier = Modifier
.testTag(TestTags.messageBubble)
.widthIn(min = 80.dp)
.clip(bubbleShape)
.combinedClickable(

View file

@ -49,13 +49,13 @@ import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.features.messages.impl.timeline.model.AggregatedReactionProvider
import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData
@ -126,9 +126,8 @@ private val ADD_EMOJI_SIZE = 16.dp
@Composable
private fun TextContent(
text: String,
modifier: Modifier = Modifier,
) = Text(
modifier = modifier
modifier = Modifier
.height(REACTION_EMOJI_LINE_HEIGHT.toDp()),
text = text,
style = ElementTheme.typography.fontBodyMdRegular,
@ -138,27 +137,24 @@ private fun TextContent(
@Composable
private fun IconContent(
@DrawableRes resourceId: Int,
modifier: Modifier = Modifier
) = Icon(
resourceId = resourceId,
contentDescription = stringResource(id = R.string.screen_room_timeline_add_reaction),
tint = ElementTheme.materialColors.secondary,
modifier = modifier
modifier = Modifier
.size(ADD_EMOJI_SIZE)
)
@Composable
private fun ReactionContent(
reaction: AggregatedReaction,
modifier: Modifier = Modifier,
) = Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
// Check if this is a custom reaction (MSC4027)
if (reaction.key.startsWith("mxc://")) {
AsyncImage(
modifier = modifier
modifier = Modifier
.heightIn(min = REACTION_EMOJI_LINE_HEIGHT.toDp(), max = REACTION_EMOJI_LINE_HEIGHT.toDp())
.aspectRatio(REACTION_IMAGE_ASPECT_RATIO, false),
model = MediaRequestData(MediaSource(reaction.key), MediaRequestData.Kind.Content),
@ -197,7 +193,7 @@ internal fun MessagesReactionButtonPreview(@PreviewParameter(AggregatedReactionP
@Composable
internal fun MessagesAddReactionButtonPreview() = ElementPreview {
MessagesReactionButton(
content = MessagesReactionsButtonContent.Icon(CommonDrawables.ic_add_reaction),
content = MessagesReactionsButtonContent.Icon(CompoundDrawables.ic_compound_reaction_add),
onClick = {},
onLongClick = {}
)

View file

@ -25,10 +25,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.utils.CommonDrawables
/**
* A swipe indicator that appears when swiping to reply to a message.
@ -49,7 +49,7 @@ fun RowScope.ReplySwipeIndicator(
alpha = swipeProgress()
},
contentDescription = null,
resourceId = CommonDrawables.ic_reply,
imageVector = CompoundIcons.Reply(),
)
}

View file

@ -91,7 +91,7 @@ fun TimelineEventTimestampView(
if (hasMessageSendingFailed && tint != null) {
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = CompoundIcons.Error,
imageVector = CompoundIcons.Error(),
contentDescription = stringResource(id = CommonStrings.common_sending_failed),
tint = tint,
modifier = Modifier.size(15.dp, 18.dp),

View file

@ -53,6 +53,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
@ -92,6 +93,7 @@ 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
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
@ -435,7 +437,7 @@ private fun MessageEventBubbleContent(
) {
Icon(
modifier = Modifier.height(14.dp),
imageVector = CompoundIcons.Threads,
imageVector = CompoundIcons.Threads(),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
)
@ -648,18 +650,53 @@ private fun ReplyToContent(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = metadata?.text.orEmpty(),
style = ElementTheme.typography.fontBodyMdRegular,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.secondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
ReplyToContentText(metadata)
}
}
}
@Composable
private fun ReplyToContentText(metadata: InReplyToMetadata?) {
val text = when (metadata) {
InReplyToMetadata.Redacted -> stringResource(id = CommonStrings.common_message_removed)
InReplyToMetadata.UnableToDecrypt -> stringResource(id = CommonStrings.common_waiting_for_decryption_key)
is InReplyToMetadata.Text -> metadata.text
is InReplyToMetadata.Thumbnail -> metadata.text
null -> ""
}
val iconResourceId = when (metadata) {
InReplyToMetadata.Redacted -> CompoundDrawables.ic_compound_delete
InReplyToMetadata.UnableToDecrypt -> CompoundDrawables.ic_compound_time
else -> null
}
val fontStyle = when (metadata) {
is InReplyToMetadata.Informative -> FontStyle.Italic
else -> FontStyle.Normal
}
Row(
verticalAlignment = Alignment.CenterVertically,
) {
if (iconResourceId != null) {
Icon(
resourceId = iconResourceId,
tint = MaterialTheme.colorScheme.secondary,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = text,
style = ElementTheme.typography.fontBodyMdRegular,
fontStyle = fontStyle,
textAlign = TextAlign.Start,
color = MaterialTheme.colorScheme.secondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowPreview() = ElementPreview {

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowWithReplyInformativePreview(
@PreviewParameter(InReplyToDetailsInformativeProvider::class) inReplyToDetails: InReplyToDetails,
) = ElementPreview {
TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails)
}
class InReplyToDetailsInformativeProvider : InReplyToDetailsProvider() {
override val values: Sequence<InReplyToDetails>
get() = sequenceOf(
RedactedContent,
UnableToDecryptContent(UnableToDecryptContent.Data.Unknown),
).map {
aInReplyToDetails(
eventContent = it,
)
}
}

View file

@ -54,6 +54,11 @@ import kotlinx.collections.immutable.persistentMapOf
internal fun TimelineItemEventRowWithReplyPreview(
@PreviewParameter(InReplyToDetailsProvider::class) inReplyToDetails: InReplyToDetails,
) = ElementPreview {
TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails)
}
@Composable
internal fun TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails: InReplyToDetails) {
Column {
sequenceOf(false, true).forEach {
ATimelineItemEventRow(
@ -83,7 +88,7 @@ internal fun TimelineItemEventRowWithReplyPreview(
}
}
class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
override val values: Sequence<InReplyToDetails>
get() = sequenceOf(
aMessageContent(
@ -156,7 +161,7 @@ class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
type = type,
)
private fun aInReplyToDetails(
protected fun aInReplyToDetails(
eventContent: EventContent,
) = InReplyToDetails(
eventId = EventId("\$event"),

View file

@ -26,9 +26,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.CommonDrawables
/**
* A flow layout for reactions that will show a collapse/expand button when the layout wraps over a defined number of rows.
@ -195,7 +195,7 @@ internal fun TimelineItemReactionsLayoutPreview() = ElementPreview {
},
addMoreButton = {
MessagesReactionButton(
content = MessagesReactionsButtonContent.Icon(CommonDrawables.ic_add_reaction),
content = MessagesReactionsButtonContent.Icon(CompoundDrawables.ic_compound_reaction_add),
onClick = {},
onLongClick = {}
)

View file

@ -31,9 +31,9 @@ import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.CommonDrawables
import kotlinx.collections.immutable.ImmutableList
@Composable
@ -99,7 +99,7 @@ private fun TimelineItemReactionsView(
addMoreButton = if (userCanSendReaction) {
{
MessagesReactionButton(
content = MessagesReactionsButtonContent.Icon(CommonDrawables.ic_add_reaction),
content = MessagesReactionsButtonContent.Icon(CompoundDrawables.ic_compound_reaction_add),
onClick = onMoreReactionsClick,
onLongClick = {}
)

View file

@ -21,9 +21,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.ui.strings.CommonStrings
@ -36,7 +36,7 @@ fun TimelineItemEncryptedView(
TimelineItemInformativeView(
text = stringResource(id = CommonStrings.common_waiting_for_decryption_key),
iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
iconResourceId = CommonDrawables.ic_waiting_to_decrypt,
iconResourceId = CompoundDrawables.ic_compound_time,
onContentLayoutChanged = onContentLayoutChanged,
modifier = modifier
)

View file

@ -37,11 +37,11 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
@Composable
fun TimelineItemFileView(
@ -62,7 +62,7 @@ fun TimelineItemFileView(
contentAlignment = Alignment.Center,
) {
Icon(
resourceId = CommonDrawables.ic_attachment,
resourceId = CompoundDrawables.ic_compound_attachment,
contentDescription = null,
tint = ElementTheme.materialColors.primary,
modifier = Modifier

View file

@ -77,7 +77,7 @@ internal fun TimelineItemInformativeViewPreview() = ElementPreview {
TimelineItemInformativeView(
text = "Info",
iconDescription = "",
iconResourceId = CompoundDrawables.ic_delete,
iconResourceId = CompoundDrawables.ic_compound_delete,
onContentLayoutChanged = {},
)
}

View file

@ -35,7 +35,7 @@ fun TimelineItemRedactedView(
TimelineItemInformativeView(
text = stringResource(id = CommonStrings.common_message_removed),
iconDescription = stringResource(id = CommonStrings.common_message_removed),
iconResourceId = CompoundDrawables.ic_delete,
iconResourceId = CompoundDrawables.ic_compound_delete,
onContentLayoutChanged = onContentLayoutChanged,
modifier = modifier
)

View file

@ -35,7 +35,7 @@ fun TimelineItemUnknownView(
TimelineItemInformativeView(
text = stringResource(id = CommonStrings.common_unsupported_event),
iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
iconResourceId = CompoundDrawables.ic_info_solid,
iconResourceId = CompoundDrawables.ic_compound_info_solid,
onContentLayoutChanged = onContentLayoutChanged,
modifier = modifier
)

View file

@ -34,6 +34,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -44,6 +45,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider
@ -58,7 +60,6 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.delay
@ -126,8 +127,8 @@ private fun PlayButton(
onClick = onClick,
enabled = enabled,
) {
Icon(
resourceId = CommonDrawables.ic_play,
ControlIcon(
imageVector = CompoundIcons.PlaySolid(),
contentDescription = stringResource(id = CommonStrings.a11y_play),
)
}
@ -140,8 +141,8 @@ private fun PauseButton(
CustomIconButton(
onClick = onClick,
) {
Icon(
resourceId = CommonDrawables.ic_pause,
ControlIcon(
imageVector = CompoundIcons.PauseSolid(),
contentDescription = stringResource(id = CommonStrings.a11y_pause),
)
}
@ -154,13 +155,25 @@ private fun RetryButton(
CustomIconButton(
onClick = onClick,
) {
Icon(
resourceId = CommonDrawables.ic_retry,
ControlIcon(
imageVector = CompoundIcons.Restart(),
contentDescription = stringResource(id = CommonStrings.action_retry),
)
}
}
@Composable
private fun ControlIcon(
imageVector: ImageVector,
contentDescription: String?,
) {
Icon(
modifier = Modifier.padding(vertical = 10.dp),
imageVector = imageVector,
contentDescription = contentDescription,
)
}
/**
* Progress button is shown when the voice message is being downloaded.
*
@ -190,8 +203,8 @@ private fun ProgressButton(
strokeWidth = 2.dp,
)
} else {
Icon(
resourceId = CommonDrawables.ic_pause,
ControlIcon(
imageVector = CompoundIcons.PauseSolid(),
contentDescription = stringResource(id = CommonStrings.a11y_pause),
)
}

View file

@ -89,7 +89,7 @@ fun GroupHeaderView(
)
Icon(
modifier = Modifier.rotate(rotation),
imageVector = CompoundIcons.ChevronRight,
imageVector = CompoundIcons.ChevronRight(),
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary
)

View file

@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
@ -106,7 +107,6 @@ fun ReactionSummaryView(
@Composable
private fun SheetContent(
summary: ReactionSummaryState.Summary,
modifier: Modifier = Modifier,
) {
val animationScope = rememberCoroutineScope()
var selectedReactionKey: String by rememberSaveable { mutableStateOf(summary.selectedKey) }
@ -127,9 +127,8 @@ private fun SheetContent(
}
Column(
modifier = modifier
.fillMaxWidth()
.fillMaxHeight()
modifier = Modifier
.fillMaxSize()
) {
LazyRow(
state = reactionListState,
@ -172,7 +171,6 @@ private fun AggregatedReactionButton(
reaction: AggregatedReaction,
isHighlighted: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val buttonColor = if (isHighlighted) {
ElementTheme.colors.bgActionPrimaryRest
@ -188,7 +186,7 @@ private fun AggregatedReactionButton(
val roundedCornerShape = RoundedCornerShape(corner = CornerSize(percent = 50))
Surface(
modifier = modifier
modifier = Modifier
.background(buttonColor, roundedCornerShape)
.clip(roundedCornerShape)
.clickable(onClick = onClick)
@ -238,10 +236,9 @@ private fun SenderRow(
name: String,
userId: String,
sentTime: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 4.dp),

View file

@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import io.element.android.appconfig.TimelineConfig
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -49,8 +50,9 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@ -68,6 +70,7 @@ fun TimelineItemReadReceiptView(
ReadReceiptsAvatars(
receipts = state.receipts,
modifier = Modifier
.testTag(TestTags.messageReadReceipts)
.clip(RoundedCornerShape(4.dp))
.clickable {
onReadReceiptsClicked()
@ -82,7 +85,7 @@ fun TimelineItemReadReceiptView(
ReadReceiptsRow(modifier) {
Icon(
modifier = Modifier.padding(2.dp),
resourceId = CommonDrawables.ic_sending,
imageVector = CompoundIcons.Circle(),
contentDescription = stringResource(id = CommonStrings.common_sending),
tint = ElementTheme.colors.iconSecondary
)
@ -98,7 +101,7 @@ fun TimelineItemReadReceiptView(
ReadReceiptsRow(modifier = modifier) {
Icon(
modifier = Modifier.padding(2.dp),
resourceId = CommonDrawables.ic_sent,
imageVector = CompoundIcons.CheckCircle(),
contentDescription = stringResource(id = CommonStrings.common_sent),
tint = ElementTheme.colors.iconSecondary
)

View file

@ -22,10 +22,15 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
class RetrySendMenuStateProvider : PreviewParameterProvider<RetrySendMenuState> {
override val values: Sequence<RetrySendMenuState> = sequenceOf(
aRetrySendMenuState(event = null),
aRetrySendMenuState(),
aRetrySendMenuState(event = aTimelineItemEvent()),
)
}
fun aRetrySendMenuState(event: TimelineItem.Event? = aTimelineItemEvent()) =
RetrySendMenuState(selectedEvent = event, eventSink = {})
fun aRetrySendMenuState(
event: TimelineItem.Event? = null,
eventSink: (RetrySendMenuEvents) -> Unit = {},
) = RetrySendMenuState(
selectedEvent = event,
eventSink = eventSink,
)

View file

@ -56,7 +56,7 @@ fun TimelineEncryptedHistoryBannerView(
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = CompoundIcons.InfoSolid,
imageVector = CompoundIcons.InfoSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconInfoPrimary
)

View file

@ -125,11 +125,10 @@ fun EventDebugInfoView(
private fun CollapsibleSection(
title: String,
text: String,
modifier: Modifier = Modifier,
initiallyExpanded: Boolean = false,
) {
var isExpanded by remember { mutableStateOf(initiallyExpanded) }
Column(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.clickable { isExpanded = !isExpanded }
@ -140,7 +139,7 @@ private fun CollapsibleSection(
Text(title, modifier = Modifier.weight(1f))
Icon(
modifier = Modifier.rotate(if (isExpanded) 180f else 0f),
imageVector = CompoundIcons.ChevronDown,
imageVector = CompoundIcons.ChevronDown(),
contentDescription = null
)
}

View file

@ -21,12 +21,20 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
@ -35,17 +43,20 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Immutable
internal sealed interface InReplyToMetadata {
val text: String?
data class Thumbnail(
val attachmentThumbnailInfo: AttachmentThumbnailInfo
) : InReplyToMetadata {
override val text: String? = attachmentThumbnailInfo.textContent
val text: String = attachmentThumbnailInfo.textContent.orEmpty()
}
data class Text(
override val text: String
val text: String
) : InReplyToMetadata
sealed interface Informative : InReplyToMetadata
data object Redacted : Informative
data object UnableToDecrypt : Informative
}
/**
@ -103,7 +114,8 @@ internal fun InReplyToDetails.metadata(): InReplyToMetadata? = when (eventConten
AttachmentThumbnailInfo(
thumbnailSource = MediaSource(eventContent.url),
textContent = eventContent.body,
type = AttachmentThumbnailType.Image
type = AttachmentThumbnailType.Image,
blurHash = eventContent.info.blurhash,
)
)
is PollContent -> InReplyToMetadata.Thumbnail(
@ -112,5 +124,13 @@ internal fun InReplyToDetails.metadata(): InReplyToMetadata? = when (eventConten
type = AttachmentThumbnailType.Poll,
)
)
else -> null
is RedactedContent -> InReplyToMetadata.Redacted
is UnableToDecryptContent -> InReplyToMetadata.UnableToDecrypt
is FailedToParseMessageLikeContent,
is FailedToParseStateContent,
is ProfileChangeContent,
is RoomMembershipContent,
is StateContent,
UnknownContent,
null -> null
}

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2024 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.typing
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.MessagesView
import io.element.android.features.messages.impl.aMessagesState
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@PreviewsDayNight
@Composable
internal fun MessagesViewWithTypingPreview(
@PreviewParameter(TypingNotificationStateForMessagesProvider::class) typingState: TypingNotificationState
) = ElementPreview {
MessagesView(
state = aMessagesState().copy(typingNotificationState = typingState),
onBackPressed = {},
onRoomDetailsClicked = {},
onEventClicked = { false },
onPreviewAttachments = {},
onUserDataClicked = {},
onSendLocationClicked = {},
onCreatePollClicked = {},
onJoinCallClicked = {},
)
}

View file

@ -0,0 +1,107 @@
/*
* Copyright (c) 2024 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.typing
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.roomMembers
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
class TypingNotificationPresenter @Inject constructor(
private val room: MatrixRoom,
private val sessionPreferencesStore: SessionPreferencesStore,
) : Presenter<TypingNotificationState> {
@Composable
override fun present(): TypingNotificationState {
val typingMembersState = remember { mutableStateOf(emptyList<RoomMember>()) }
val renderTypingNotifications by sessionPreferencesStore.isRenderTypingNotificationsEnabled().collectAsState(initial = true)
LaunchedEffect(renderTypingNotifications) {
if (renderTypingNotifications) {
observeRoomTypingMembers(typingMembersState)
} else {
typingMembersState.value = emptyList()
}
}
// This will keep the space reserved for the typing notifications after the first one is displayed
var reserveSpace by remember { mutableStateOf(false) }
LaunchedEffect(renderTypingNotifications, typingMembersState.value) {
if (renderTypingNotifications && typingMembersState.value.isNotEmpty()) {
reserveSpace = true
}
}
return TypingNotificationState(
renderTypingNotifications = renderTypingNotifications,
typingMembers = typingMembersState.value.toImmutableList(),
reserveSpace = reserveSpace,
)
}
private fun CoroutineScope.observeRoomTypingMembers(typingMembersState: MutableState<List<RoomMember>>) {
combine(room.roomTypingMembersFlow, room.membersStateFlow) { typingMembers, membersState ->
typingMembers
.map { userId ->
membersState.roomMembers()
?.firstOrNull { roomMember -> roomMember.userId == userId }
?: createDefaultRoomMemberForTyping(userId)
}
}
.distinctUntilChanged()
.onEach { members ->
typingMembersState.value = members
}
.launchIn(this)
}
}
/**
* Create a default [RoomMember] for typing events.
* In this case, only the userId will be used for rendering, other fields are not used, but keep them
* as close as possible to the actual data.
*/
private fun createDefaultRoomMemberForTyping(userId: UserId): RoomMember {
return RoomMember(
userId = userId,
displayName = null,
avatarUrl = null,
membership = RoomMembershipState.JOIN,
isNameAmbiguous = false,
powerLevel = 0,
normalizedPowerLevel = 0,
isIgnored = false,
)
}

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2024 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.typing
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.ImmutableList
/**
* State for the typing notification view.
*/
data class TypingNotificationState(
/** Whether to render the typing notifications based on the user's preferences. */
val renderTypingNotifications: Boolean,
/** The room members currently typing. */
val typingMembers: ImmutableList<RoomMember>,
/** Whether to reserve space for the typing notifications at the bottom of the timeline. */
val reserveSpace: Boolean,
)

View file

@ -0,0 +1,36 @@
/*
* Copyright (c) 2024 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.typing
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
class TypingNotificationStateForMessagesProvider : PreviewParameterProvider<TypingNotificationState> {
override val values: Sequence<TypingNotificationState>
get() = sequenceOf(
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
aTypingRoomMember(displayName = "Bob"),
),
),
aTypingNotificationState(
typingMembers = listOf(aTypingRoomMember()),
reserveSpace = true
),
aTypingNotificationState(reserveSpace = true),
)
}

View file

@ -0,0 +1,102 @@
/*
* 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.typing
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import kotlinx.collections.immutable.toImmutableList
class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificationState> {
override val values: Sequence<TypingNotificationState>
get() = sequenceOf(
aTypingNotificationState(),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice", isNameAmbiguous = true),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
aTypingRoomMember(displayName = "Bob"),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
aTypingRoomMember(displayName = "Bob"),
aTypingRoomMember(displayName = "Charlie"),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
aTypingRoomMember(displayName = "Bob"),
aTypingRoomMember(displayName = "Charlie"),
aTypingRoomMember(displayName = "Dan"),
aTypingRoomMember(displayName = "Eve"),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice with a very long display name which means that it will be truncated"),
),
),
aTypingNotificationState(
typingMembers = emptyList(),
reserveSpace = true,
),
)
}
internal fun aTypingNotificationState(
typingMembers: List<RoomMember> = emptyList(),
reserveSpace: Boolean = false,
) = TypingNotificationState(
renderTypingNotifications = true,
typingMembers = typingMembers.toImmutableList(),
reserveSpace = reserveSpace,
)
internal fun aTypingRoomMember(
userId: UserId = UserId("@alice:example.com"),
displayName: String? = null,
isNameAmbiguous: Boolean = false,
): RoomMember {
return RoomMember(
userId = userId,
displayName = displayName,
avatarUrl = null,
membership = RoomMembershipState.JOIN,
isNameAmbiguous = isNameAmbiguous,
powerLevel = 0,
normalizedPowerLevel = 0,
isIgnored = false,
)
}

View file

@ -0,0 +1,155 @@
/*
* Copyright (c) 2024 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.typing
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.ImmutableList
@Composable
fun TypingNotificationView(
state: TypingNotificationState,
modifier: Modifier = Modifier,
) {
val displayNotifications = state.typingMembers.isNotEmpty() && state.renderTypingNotifications
@Suppress("ModifierNaming")
@Composable fun TypingText(text: AnnotatedString, textModifier: Modifier = Modifier) {
Text(
modifier = textModifier,
text = text,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
// Display the typing notification space when either a typing notification needs to be displayed or a previous one already was
AnimatedVisibility(
modifier = modifier.fillMaxWidth().padding(vertical = 2.dp),
visible = displayNotifications || state.reserveSpace,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
val typingNotificationText = computeTypingNotificationText(state.typingMembers)
Box(contentAlignment = Alignment.BottomStart) {
// Reserve the space for the typing notification by adding an invisible text
TypingText(
text = typingNotificationText,
textModifier = Modifier
.alpha(0f)
// Remove the semantics of the text to avoid screen readers to read it
.clearAndSetSemantics { }
)
// Display the actual notification
AnimatedVisibility(
visible = displayNotifications,
enter = fadeIn(),
exit = fadeOut(),
) {
TypingText(text = typingNotificationText, textModifier = Modifier.padding(horizontal = 24.dp))
}
}
}
}
@Composable
private fun computeTypingNotificationText(typingMembers: ImmutableList<RoomMember>): AnnotatedString {
// Remember the last value to avoid empty typing messages while animating
var result by remember { mutableStateOf(AnnotatedString("")) }
if (typingMembers.isNotEmpty()) {
val names = when (typingMembers.size) {
0 -> "" // Cannot happen
1 -> typingMembers[0].disambiguatedDisplayName
2 -> stringResource(
id = R.string.screen_room_typing_two_members,
typingMembers[0].disambiguatedDisplayName,
typingMembers[1].disambiguatedDisplayName,
)
else -> pluralStringResource(
id = R.plurals.screen_room_typing_many_members,
count = typingMembers.size - 2,
typingMembers[0].disambiguatedDisplayName,
typingMembers[1].disambiguatedDisplayName,
typingMembers.size - 2,
)
}
// Get the translated string with a fake pattern
val tmpString = pluralStringResource(
id = R.plurals.screen_room_typing_notification,
count = typingMembers.size,
"<>",
)
// Split the string in 3 parts
val parts = tmpString.split("<>")
// And rebuild the string with the names
result = buildAnnotatedString {
append(parts[0])
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(names)
}
append(parts[1])
}
}
return result
}
@PreviewsDayNight
@Composable
internal fun TypingNotificationViewPreview(
@PreviewParameter(TypingNotificationStateProvider::class) state: TypingNotificationState,
) = ElementPreview {
TypingNotificationView(
modifier = if (state.reserveSpace) Modifier.border(1.dp, Color.Blue) else Modifier,
state = state,
)
}

View file

@ -12,6 +12,14 @@
<item quantity="one">"%1$d Raumänderung"</item>
<item quantity="other">"%1$d Raumänderungen"</item>
</plurals>
<plurals name="screen_room_typing_many_members">
<item quantity="one">"%1$s, %2$s und %3$d weitere Person"</item>
<item quantity="other">"%1$s, %2$s und %3$d weitere Person"</item>
</plurals>
<plurals name="screen_room_typing_notification">
<item quantity="one">"%1$s schreibt…"</item>
<item quantity="other">"%1$s schreiben…"</item>
</plurals>
<string name="report_content_explanation">"Diese Meldung wird an den Administrator deines Homeservers weitergeleitet. Dieser kann keine verschlüsselten Nachrichten lesen."</string>
<string name="report_content_hint">"Grund für die Meldung dieses Inhalts"</string>
<string name="room_timeline_beginning_of_room">"Dies ist der Anfang von %1$s."</string>
@ -52,6 +60,7 @@
<string name="screen_room_retry_send_menu_title">"Deine Nachricht konnte nicht gesendet werden"</string>
<string name="screen_room_timeline_add_reaction">"Emoji hinzufügen"</string>
<string name="screen_room_timeline_less_reactions">"Weniger anzeigen"</string>
<string name="screen_room_typing_two_members">"%1$s und %2$s"</string>
<string name="screen_room_voice_message_tooltip">"Zum Aufnehmen gedrückt halten"</string>
<string name="screen_room_mentions_at_room_title">"Alle"</string>
<string name="screen_report_content_block_user">"Benutzer blockieren"</string>

View file

@ -12,6 +12,14 @@
<item quantity="one">"%1$d szobaváltozás"</item>
<item quantity="other">"%1$d szobaváltozás"</item>
</plurals>
<plurals name="screen_room_typing_many_members">
<item quantity="one">"%1$s, %2$s és %3$d további felhasználó"</item>
<item quantity="other">"%1$s, %2$s és %3$d további felhasználó"</item>
</plurals>
<plurals name="screen_room_typing_notification">
<item quantity="one">"%1$s éppen ír…"</item>
<item quantity="other">"%1$s éppen ír…"</item>
</plurals>
<string name="report_content_explanation">"Ez az üzenet jelentve lesz a Matrix-kiszolgáló rendszergazdájának. Nem fogja tudni elolvasni a titkosított üzeneteket."</string>
<string name="report_content_hint">"A tartalom jelentésének oka"</string>
<string name="room_timeline_beginning_of_room">"Ez a(z) %1$s kezdete."</string>
@ -52,6 +60,7 @@
<string name="screen_room_retry_send_menu_title">"Az üzenet elküldése sikertelen"</string>
<string name="screen_room_timeline_add_reaction">"Emodzsi hozzáadása"</string>
<string name="screen_room_timeline_less_reactions">"Kevesebb megjelenítése"</string>
<string name="screen_room_typing_two_members">"%1$s és %2$s"</string>
<string name="screen_room_voice_message_tooltip">"Tartsa a rögzítéshez"</string>
<string name="screen_room_mentions_at_room_title">"Mindenki"</string>
<string name="screen_report_content_block_user">"Felhasználó letiltása"</string>

View file

@ -13,6 +13,16 @@
<item quantity="few">"%1$d zmeny miestnosti"</item>
<item quantity="other">"%1$d zmien miestnosti"</item>
</plurals>
<plurals name="screen_room_typing_many_members">
<item quantity="one">"%1$s, %2$s a %3$d ďalší"</item>
<item quantity="few">"%1$s, %2$s a %3$d ďalší"</item>
<item quantity="other">"%1$s, %2$s a %3$d ďalší"</item>
</plurals>
<plurals name="screen_room_typing_notification">
<item quantity="one">"%1$s píše"</item>
<item quantity="few">"%1$s píšu"</item>
<item quantity="other">"%1$s píšu"</item>
</plurals>
<string name="report_content_explanation">"Táto správa bude nahlásená správcovi vášho domovského servera. Nebude môcť prečítať žiadne šifrované správy."</string>
<string name="report_content_hint">"Dôvod nahlásenia tohto obsahu"</string>
<string name="room_timeline_beginning_of_room">"Toto je začiatok %1$s."</string>
@ -53,6 +63,7 @@
<string name="screen_room_retry_send_menu_title">"Vašu správu sa nepodarilo odoslať"</string>
<string name="screen_room_timeline_add_reaction">"Pridať emoji"</string>
<string name="screen_room_timeline_less_reactions">"Zobraziť menej"</string>
<string name="screen_room_typing_two_members">"%1$s a %2$s"</string>
<string name="screen_room_voice_message_tooltip">"Podržaním nahrajte"</string>
<string name="screen_room_mentions_at_room_title">"Všetci"</string>
<string name="screen_report_content_block_user">"Zablokovať používateľa"</string>

View file

@ -42,6 +42,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.typing.TypingNotificationPresenter
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
@ -96,7 +97,9 @@ import io.element.android.tests.testutils.consumeItemsUntilTimeout
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@ -129,6 +132,21 @@ class MessagesPresenterTest {
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - check that the room's unread flag is removed`() = runTest {
val room = FakeMatrixRoom()
assertThat(room.markAsReadCalls).isEmpty()
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
runCurrent()
assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - call is disabled if user cannot join it even if there is an ongoing call`() = runTest {
val room = FakeMatrixRoom().apply {
@ -713,6 +731,10 @@ class MessagesPresenterTest {
}
}
val actionListPresenter = ActionListPresenter(appPreferencesStore = appPreferencesStore)
val typingNotificationPresenter = TypingNotificationPresenter(
room = matrixRoom,
sessionPreferencesStore = sessionPreferencesStore,
)
val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter()
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
@ -722,6 +744,7 @@ class MessagesPresenterTest {
composerPresenter = messageComposerPresenter,
voiceMessageComposerPresenter = voiceMessageComposerPresenter,
timelinePresenterFactory = timelinePresenterFactory,
typingNotificationPresenter = typingNotificationPresenter,
actionListPresenter = actionListPresenter,
customReactionPresenter = customReactionPresenter,
reactionSummaryPresenter = reactionSummaryPresenter,

View file

@ -0,0 +1,503 @@
/*
* Copyright (c) 2024 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
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeRight
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.aRetrySendMenuState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithParam
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithParamAndResult
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MessagesViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invoke expected callback`() {
val eventsRecorder = EventsRecorder<MessagesEvents>(expectEvents = false)
val state = aMessagesState(
eventSink = eventsRecorder
)
ensureCalledOnce { callback ->
rule.setMessagesView(
state = state,
onBackPressed = callback,
)
rule.pressBack()
}
}
@Test
fun `clicking on room name invoke expected callback`() {
val eventsRecorder = EventsRecorder<MessagesEvents>(expectEvents = false)
val state = aMessagesState(
eventSink = eventsRecorder
)
ensureCalledOnce { callback ->
rule.setMessagesView(
state = state,
onRoomDetailsClicked = callback,
)
rule.onNodeWithText(state.roomName.dataOrNull().orEmpty()).performClick()
}
}
@Test
fun `clicking on join call invoke expected callback`() {
val eventsRecorder = EventsRecorder<MessagesEvents>(expectEvents = false)
val state = aMessagesState(
eventSink = eventsRecorder
)
ensureCalledOnce { callback ->
rule.setMessagesView(
state = state,
onJoinCallClicked = callback,
)
val joinCallContentDescription = rule.activity.getString(CommonStrings.a11y_start_call)
rule.onNodeWithContentDescription(joinCallContentDescription).performClick()
}
}
@Test
fun `clicking on an Event invoke expected callback`() {
val eventsRecorder = EventsRecorder<MessagesEvents>(expectEvents = false)
val state = aMessagesState(
eventSink = eventsRecorder
)
val timelineItem = state.timelineState.timelineItems.first()
val callback = EnsureCalledOnceWithParam(
expectedParam = timelineItem,
result = true,
)
rule.setMessagesView(
state = state,
onEventClicked = callback,
)
// Cannot perform click on "Text", it's not detected. Use tag instead
rule.onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performClick()
callback.assertSuccess()
}
@Test
fun `clicking on an Event timestamp in error emits the expected Event`() {
val eventsRecorder = EventsRecorder<RetrySendMenuEvents>()
val state = aMessagesState(
retrySendMenuState = aRetrySendMenuState(
eventSink = eventsRecorder
),
)
val timelineItem = state.timelineState.timelineItems[1] as TimelineItem.Event
rule.setMessagesView(
state = state,
)
rule.onAllNodesWithText(timelineItem.sentTime)[1].performClick()
eventsRecorder.assertSingle(RetrySendMenuEvents.EventSelected(timelineItem))
}
@Test
fun `long clicking on an Event emits the expected Event userHasPermissionToSendMessage`() {
`long clicking on an Event emits the expected Event`(userHasPermissionToSendMessage = true)
}
@Test
fun `long clicking on an Event emits the expected Event userHasPermissionToRedactOwn`() {
`long clicking on an Event emits the expected Event`(userHasPermissionToRedactOwn = true)
}
@Test
fun `long clicking on an Event emits the expected Event userHasPermissionToRedactOther`() {
`long clicking on an Event emits the expected Event`(userHasPermissionToRedactOther = true)
}
@Test
fun `long clicking on an Event emits the expected Event userHasPermissionToSendReaction`() {
`long clicking on an Event emits the expected Event`(userHasPermissionToSendReaction = true)
}
private fun `long clicking on an Event emits the expected Event`(
userHasPermissionToSendMessage: Boolean = false,
userHasPermissionToRedactOwn: Boolean = false,
userHasPermissionToRedactOther: Boolean = false,
userHasPermissionToSendReaction: Boolean = false,
) {
val eventsRecorder = EventsRecorder<ActionListEvents>()
val state = aMessagesState(
actionListState = anActionListState(
eventSink = eventsRecorder
),
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToRedactOwn = userHasPermissionToRedactOwn,
userHasPermissionToRedactOther = userHasPermissionToRedactOther,
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
)
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView(
state = state,
)
// Cannot perform click on "Text", it's not detected. Use tag instead
rule.onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performTouchInput { longClick() }
eventsRecorder.assertSingle(
ActionListEvents.ComputeForMessage(
event = timelineItem,
canRedactOwn = state.userHasPermissionToRedactOwn,
canRedactOther = state.userHasPermissionToRedactOther,
canSendMessage = state.userHasPermissionToSendMessage,
canSendReaction = state.userHasPermissionToSendReaction,
)
)
}
@Test
fun `clicking on a read receipt list emits the expected Event`() {
val eventsRecorder = EventsRecorder<ReadReceiptBottomSheetEvents>()
val state = aMessagesState(
timelineState = aTimelineState(
renderReadReceipts = true,
timelineItems = persistentListOf(
aTimelineItemEvent(
readReceiptState = aTimelineItemReadReceipts(
receipts = listOf(
aReadReceiptData(0),
),
),
),
),
),
readReceiptBottomSheetState = aReadReceiptBottomSheetState(
eventSink = eventsRecorder
),
)
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView(
state = state,
)
rule.onNodeWithTag(TestTags.messageReadReceipts.value).performClick()
eventsRecorder.assertSingle(ReadReceiptBottomSheetEvents.EventSelected(timelineItem))
}
@Test
fun `swiping on an Event emits the expected Event`() {
swipeTest(userHasPermissionToSendMessage = true)
}
@Test
fun `swiping on an Event emits no Event if user does not have permission to send message`() {
swipeTest(userHasPermissionToSendMessage = false)
}
private fun swipeTest(userHasPermissionToSendMessage: Boolean) {
val eventsRecorder = EventsRecorder<MessagesEvents>()
val state = aMessagesState(
timelineState = aTimelineState(
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
timelineRoomInfo = aTimelineRoomInfo(
userHasPermissionToSendMessage = userHasPermissionToSendMessage
),
),
eventSink = eventsRecorder,
)
rule.setMessagesView(
state = state,
)
rule.onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performTouchInput { swipeRight(endX = 200f) }
if (userHasPermissionToSendMessage) {
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
eventsRecorder.assertSingle(MessagesEvents.HandleAction(TimelineItemAction.Reply, timelineItem))
} else {
eventsRecorder.assertEmpty()
}
}
@Test
fun `clicking on send location invoke expected callback`() {
val eventsRecorder = EventsRecorder<MessagesEvents>(expectEvents = false)
val state = aMessagesState(
composerState = aMessageComposerState(
showAttachmentSourcePicker = true
),
eventSink = eventsRecorder
)
ensureCalledOnce { callback ->
rule.setMessagesView(
state = state,
onSendLocationClicked = callback,
)
rule.clickOn(R.string.screen_room_attachment_source_location)
}
}
@Test
fun `clicking on create poll invoke expected callback`() {
val eventsRecorder = EventsRecorder<MessagesEvents>(expectEvents = false)
val state = aMessagesState(
composerState = aMessageComposerState(
showAttachmentSourcePicker = true
),
eventSink = eventsRecorder
)
ensureCalledOnce { callback ->
rule.setMessagesView(
state = state,
onCreatePollClicked = callback,
)
// Then click on the poll action
rule.clickOn(R.string.screen_room_attachment_source_poll)
}
}
@Test
fun `clicking on the sender of an Event invoke expected callback`() {
val eventsRecorder = EventsRecorder<MessagesEvents>(expectEvents = false)
val state = aMessagesState(
eventSink = eventsRecorder
)
val timelineItem = state.timelineState.timelineItems.first()
ensureCalledOnceWithParam(
param = (timelineItem as TimelineItem.Event).senderId
) { callback ->
rule.setMessagesView(
state = state,
onUserDataClicked = callback,
)
val senderName = (timelineItem as? TimelineItem.Event)?.senderDisplayName.orEmpty()
rule.onNodeWithText(senderName).performClick()
}
}
@Test
fun `selecting a action on a message emits the expected Event`() {
val eventsRecorder = EventsRecorder<MessagesEvents>()
val state = aMessagesState(
eventSink = eventsRecorder
)
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
val stateWithMessageAction = state.copy(
actionListState = anActionListState(
target = ActionListState.Target.Success(
event = timelineItem,
displayEmojiReactions = true,
actions = persistentListOf(TimelineItemAction.Edit),
)
),
)
rule.setMessagesView(
state = stateWithMessageAction,
)
rule.clickOn(CommonStrings.action_edit)
// Give time for the close animation to complete
rule.mainClock.advanceTimeBy(milliseconds = 1_000)
eventsRecorder.assertSingle(MessagesEvents.HandleAction(TimelineItemAction.Edit, timelineItem))
}
@Test
fun `clicking on a reaction emits the expected Event`() {
val eventsRecorder = EventsRecorder<MessagesEvents>()
val state = aMessagesState(
eventSink = eventsRecorder
)
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView(
state = state,
)
rule.onAllNodesWithText("👍️").onFirst().performClick()
eventsRecorder.assertSingle(MessagesEvents.ToggleReaction("👍️", timelineItem.eventId!!))
}
@Test
fun `long clicking on a reaction emits the expected Event`() {
val eventsRecorder = EventsRecorder<ReactionSummaryEvents>()
val state = aMessagesState(
reactionSummaryState = aReactionSummaryState(
target = null,
eventSink = eventsRecorder,
),
)
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView(
state = state,
)
rule.onAllNodesWithText("👍️").onFirst().performTouchInput { longClick() }
eventsRecorder.assertSingle(ReactionSummaryEvents.ShowReactionSummary(timelineItem.eventId!!, timelineItem.reactionsState.reactions, "👍️"))
}
@Test
fun `clicking on more reaction emits the expected Event`() {
val eventsRecorder = EventsRecorder<CustomReactionEvents>()
val state = aMessagesState(
customReactionState = aCustomReactionState(
eventSink = eventsRecorder,
),
)
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView(
state = state,
)
val moreReactionContentDescription = rule.activity.getString(R.string.screen_room_timeline_add_reaction)
rule.onAllNodesWithContentDescription(moreReactionContentDescription).onFirst().performClick()
eventsRecorder.assertSingle(CustomReactionEvents.ShowCustomReactionSheet(timelineItem))
}
@Test
fun `clicking on more reaction from action list emits the expected Event`() {
val eventsRecorder = EventsRecorder<CustomReactionEvents>()
val state = aMessagesState()
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
val stateWithActionListState = state.copy(
actionListState = anActionListState(
target = ActionListState.Target.Success(
event = timelineItem,
displayEmojiReactions = true,
actions = persistentListOf(TimelineItemAction.Edit),
),
),
customReactionState = aCustomReactionState(
eventSink = eventsRecorder
),
)
rule.setMessagesView(
state = stateWithActionListState,
)
val moreReactionContentDescription = rule.activity.getString(CommonStrings.a11y_react_with_other_emojis)
rule.onNodeWithContentDescription(moreReactionContentDescription).performClick()
// Give time for the close animation to complete
rule.mainClock.advanceTimeBy(milliseconds = 1_000)
eventsRecorder.assertSingle(CustomReactionEvents.ShowCustomReactionSheet(timelineItem))
}
@Test
fun `clicking on a custom emoji emits the expected Events`() {
val aUnicode = "🙈"
val customReactionStateEventsRecorder = EventsRecorder<CustomReactionEvents>()
val eventsRecorder = EventsRecorder<MessagesEvents>()
val state = aMessagesState(
eventSink = eventsRecorder,
)
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
val stateWithCustomReactionState = state.copy(
customReactionState = aCustomReactionState(
target = CustomReactionState.Target.Success(
event = timelineItem,
emojibaseStore = EmojibaseStore(
categories = mapOf(
EmojibaseCategory.People to listOf(
Emoji(
hexcode = "",
label = "",
tags = emptyList(),
shortcodes = emptyList(),
unicode = aUnicode,
skins = null,
)
)
)
),
),
eventSink = customReactionStateEventsRecorder
),
)
rule.setMessagesView(
state = stateWithCustomReactionState,
)
rule.onNodeWithText(aUnicode, useUnmergedTree = true).performClick()
// Give time for the close animation to complete
rule.mainClock.advanceTimeBy(milliseconds = 1_000)
customReactionStateEventsRecorder.assertSingle(CustomReactionEvents.DismissCustomReactionSheet)
eventsRecorder.assertSingle(MessagesEvents.ToggleReaction(aUnicode, timelineItem.eventId!!))
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessagesView(
state: MessagesState,
onBackPressed: () -> Unit = EnsureNeverCalled(),
onRoomDetailsClicked: () -> Unit = EnsureNeverCalled(),
onEventClicked: (event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithParamAndResult(),
onUserDataClicked: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit = EnsureNeverCalledWithParam(),
onSendLocationClicked: () -> Unit = EnsureNeverCalled(),
onCreatePollClicked: () -> Unit = EnsureNeverCalled(),
onJoinCallClicked: () -> Unit = EnsureNeverCalled(),
) {
setContent {
// Cannot use the RichTextEditor, so simulate a LocalInspectionMode
CompositionLocalProvider(LocalInspectionMode provides true) {
MessagesView(
state = state,
onBackPressed = onBackPressed,
onRoomDetailsClicked = onRoomDetailsClicked,
onEventClicked = onEventClicked,
onUserDataClicked = onUserDataClicked,
onPreviewAttachments = onPreviewAttachments,
onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked,
onJoinCallClicked = onJoinCallClicked,
)
}
}
}

View file

@ -45,6 +45,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSende
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
@ -60,8 +61,11 @@ import io.element.android.tests.testutils.awaitWithLatch
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
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
@ -69,6 +73,7 @@ import java.util.Date
import kotlin.time.Duration.Companion.seconds
private const val FAKE_UNIQUE_ID = "FAKE_UNIQUE_ID"
private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
class TimelinePresenterTest {
@get:Rule
@ -125,11 +130,45 @@ class TimelinePresenterTest {
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - on scroll finished mark a room as read if the first visible index is 0`() = runTest(StandardTestDispatcher()) {
val timeline = FakeMatrixTimeline(
initialTimelineItems = listOf(
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem())
)
)
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
val room = FakeMatrixRoom(matrixTimeline = timeline)
val presenter = createTimelinePresenter(
timeline = timeline,
room = room,
sessionPreferencesStore = sessionPreferencesStore,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
assertThat(timeline.sentReadReceipts).isEmpty()
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
runCurrent()
assertThat(room.markAsReadCalls).isNotEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
val timeline = FakeMatrixTimeline(
initialTimelineItems = listOf(
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem())
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
MatrixTimelineItem.Event(
uniqueId = FAKE_UNIQUE_ID_2,
event = anEventTimelineItem(
eventId = AN_EVENT_ID_2,
content = aMessageContent("Test message")
)
)
)
)
val presenter = createTimelinePresenter(timeline)
@ -140,7 +179,7 @@ class TimelinePresenterTest {
val initialState = awaitFirstItem()
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
}
assertThat(timeline.sentReadReceipts).isNotEmpty()
assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ)
@ -149,10 +188,17 @@ class TimelinePresenterTest {
}
@Test
fun `present - on scroll finished send a private read receipt if an event is before the index and public read receipts are disabled`() = runTest {
fun `present - on scroll finished send a private read receipt if an event is at an index other than 0 and public read receipts are disabled`() = runTest {
val timeline = FakeMatrixTimeline(
initialTimelineItems = listOf(
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem())
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
MatrixTimelineItem.Event(
uniqueId = FAKE_UNIQUE_ID_2,
event = anEventTimelineItem(
eventId = AN_EVENT_ID_2,
content = aMessageContent("Test message")
)
)
)
)
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
@ -168,6 +214,7 @@ class TimelinePresenterTest {
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
}
assertThat(timeline.sentReadReceipts).isNotEmpty()
assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ_PRIVATE)
@ -176,10 +223,17 @@ class TimelinePresenterTest {
}
@Test
fun `present - on scroll finished will not send read receipt if no event is before the index`() = runTest {
fun `present - on scroll finished will not send read receipt the first visible event is the same as before`() = runTest {
val timeline = FakeMatrixTimeline(
initialTimelineItems = listOf(
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem())
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
MatrixTimelineItem.Event(
uniqueId = FAKE_UNIQUE_ID_2,
event = anEventTimelineItem(
eventId = AN_EVENT_ID_2,
content = aMessageContent("Test message")
)
)
)
)
val presenter = createTimelinePresenter(timeline)
@ -191,8 +245,9 @@ class TimelinePresenterTest {
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
}
assertThat(timeline.sentReadReceipts).isEmpty()
assertThat(timeline.sentReadReceipts).hasSize(1)
cancelAndIgnoreRemainingEvents()
}
}
@ -201,6 +256,7 @@ class TimelinePresenterTest {
fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest {
val timeline = FakeMatrixTimeline(
initialTimelineItems = listOf(
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker),
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker)
)
)
@ -212,7 +268,7 @@ class TimelinePresenterTest {
val initialState = awaitFirstItem()
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
}
assertThat(timeline.sentReadReceipts).isEmpty()
cancelAndIgnoreRemainingEvents()

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
import io.element.android.tests.testutils.EventsRecorder
@ -41,6 +42,7 @@ class TimelineViewTest {
hasMoreToLoadBackwards = true,
)
),
typingNotificationState = aTypingNotificationState(),
roomName = null,
onUserDataClicked = EnsureNeverCalledWithParam(),
onMessageClicked = EnsureNeverCalledWithParam(),
@ -67,6 +69,7 @@ class TimelineViewTest {
hasMoreToLoadBackwards = false,
)
),
typingNotificationState = aTypingNotificationState(),
roomName = null,
onUserDataClicked = EnsureNeverCalledWithParam(),
onMessageClicked = EnsureNeverCalledWithParam(),

View file

@ -31,13 +31,23 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -45,11 +55,13 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.aPollContent
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Duration.Companion.minutes
@RunWith(AndroidJUnit4::class)
class InReplyToMetadataKtTest {
@ -72,7 +84,7 @@ class InReplyToMetadataKtTest {
messageType = ImageMessageType(
body = "body",
source = aMediaSource(),
info = null,
info = anImageInfo(),
)
)
).metadata()
@ -84,7 +96,33 @@ class InReplyToMetadataKtTest {
thumbnailSource = aMediaSource(),
textContent = "body",
type = AttachmentThumbnailType.Image,
blurHash = null,
blurHash = A_BLUR_HASH,
)
)
)
}
}
}
@Test
fun `a sticker message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = StickerContent(
body = "body",
info = anImageInfo(),
url = "url"
)
).metadata()
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = aMediaSource(url = "url"),
textContent = "body",
type = AttachmentThumbnailType.Image,
blurHash = A_BLUR_HASH,
)
)
)
@ -100,16 +138,7 @@ class InReplyToMetadataKtTest {
messageType = VideoMessageType(
body = "body",
source = aMediaSource(),
info = VideoInfo(
duration = null,
height = null,
width = null,
mimetype = null,
size = null,
thumbnailInfo = null,
thumbnailSource = aMediaSource(),
blurhash = null
),
info = aVideoInfo(),
)
)
).metadata()
@ -121,7 +150,7 @@ class InReplyToMetadataKtTest {
thumbnailSource = aMediaSource(),
textContent = "body",
type = AttachmentThumbnailType.Video,
blurHash = null,
blurHash = A_BLUR_HASH,
)
)
)
@ -277,11 +306,115 @@ class InReplyToMetadataKtTest {
}
@Test
fun `any other content`() = runTest {
fun `redacted content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = RedactedContent
).metadata()
}.test {
awaitItem().let {
assertThat(it).isEqualTo(InReplyToMetadata.Redacted)
}
}
}
@Test
fun `unable to decrypt content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)
).metadata()
}.test {
awaitItem().let {
assertThat(it).isEqualTo(InReplyToMetadata.UnableToDecrypt)
}
}
}
@Test
fun `failed to parse message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = FailedToParseMessageLikeContent("", "")
).metadata()
}.test {
awaitItem().let {
assertThat(it).isNull()
}
}
}
@Test
fun `failed to parse state content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = FailedToParseStateContent("", "", "")
).metadata()
}.test {
awaitItem().let {
assertThat(it).isNull()
}
}
}
@Test
fun `profile change content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = ProfileChangeContent("", "", "", "")
).metadata()
}.test {
awaitItem().let {
assertThat(it).isNull()
}
}
}
@Test
fun `room membership content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = RoomMembershipContent(A_USER_ID, null)
).metadata()
}.test {
awaitItem().let {
assertThat(it).isNull()
}
}
}
@Test
fun `state content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = StateContent("", OtherState.RoomJoinRules)
).metadata()
}.test {
awaitItem().let {
assertThat(it).isNull()
}
}
}
@Test
fun `unknown content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = UnknownContent
).metadata()
}.test {
awaitItem().let {
assertThat(it).isNull()
}
}
}
@Test
fun `null content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = null
).metadata()
}.test {
awaitItem().let {
assertThat(it).isNull()
@ -306,6 +439,31 @@ fun anInReplyToDetails(
textContent = textContent,
)
fun aVideoInfo(): VideoInfo {
return VideoInfo(
duration = 1.minutes,
height = 100,
width = 100,
mimetype = "video/mp4",
size = 1000,
thumbnailInfo = null,
thumbnailSource = aMediaSource(),
blurhash = A_BLUR_HASH,
)
}
fun anImageInfo(): ImageInfo {
return ImageInfo(
height = 100,
width = 100,
mimetype = "image/jpeg",
size = 1000,
thumbnailInfo = null,
thumbnailSource = aMediaSource(),
blurhash = A_BLUR_HASH,
)
}
@Composable
private fun testEnv(content: @Composable () -> Any?): Any? {
var result: Any? = null

View file

@ -0,0 +1,242 @@
/*
* Copyright (c) 2024 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.typing
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.Event
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@Suppress("LargeClass")
class TypingNotificationPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.renderTypingNotifications).isTrue()
assertThat(initialState.typingMembers).isEmpty()
assertThat(initialState.reserveSpace).isFalse()
}
}
@Test
fun `present - typing notification disabled`() = runTest {
val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2)
val room = FakeMatrixRoom()
val sessionPreferencesStore = InMemorySessionPreferencesStore(
isRenderTypingNotificationsEnabled = false
)
val presenter = createPresenter(
matrixRoom = room,
sessionPreferencesStore = sessionPreferencesStore,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.renderTypingNotifications).isFalse()
assertThat(initialState.typingMembers).isEmpty()
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
expectNoEvents()
// Preferences changes
sessionPreferencesStore.setRenderTypingNotifications(true)
skipItems(1)
val oneMemberTypingState = awaitItem()
assertThat(oneMemberTypingState.renderTypingNotifications).isTrue()
assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1)
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember)
// Preferences changes again
sessionPreferencesStore.setRenderTypingNotifications(false)
skipItems(2)
val finalState = awaitItem()
assertThat(finalState.renderTypingNotifications).isFalse()
assertThat(finalState.typingMembers).isEmpty()
}
}
@Test
fun `present - state is updated when a member is typing, member is not known`() = runTest {
val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2)
val room = FakeMatrixRoom()
val presenter = createPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.typingMembers).isEmpty()
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
val oneMemberTypingState = awaitItem()
assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1)
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember)
// User stops typing
room.givenRoomTypingMembers(emptyList())
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.typingMembers).isEmpty()
}
}
@Test
fun `present - state is updated when a member is typing, member is known`() = runTest {
val aKnownRoomMember = createKnownRoomMember(userId = A_USER_ID_2)
val room = FakeMatrixRoom().apply {
givenRoomMembersState(
MatrixRoomMembersState.Ready(
listOf(
createKnownRoomMember(A_USER_ID),
aKnownRoomMember,
createKnownRoomMember(A_USER_ID_3),
createKnownRoomMember(A_USER_ID_4),
).toImmutableList()
)
)
}
val presenter = createPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.typingMembers).isEmpty()
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
val oneMemberTypingState = awaitItem()
assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1)
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aKnownRoomMember)
// User stops typing
room.givenRoomTypingMembers(emptyList())
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.typingMembers).isEmpty()
}
}
@Test
fun `present - state is updated when a member is typing, member is not known, then known`() = runTest {
val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2)
val aKnownRoomMember = createKnownRoomMember(A_USER_ID_2)
val room = FakeMatrixRoom()
val presenter = createPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.typingMembers).isEmpty()
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
val oneMemberTypingState = awaitItem()
assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1)
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember)
// User is getting known
room.givenRoomMembersState(
MatrixRoomMembersState.Ready(
listOf(aKnownRoomMember).toImmutableList()
)
)
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.typingMembers.first()).isEqualTo(aKnownRoomMember)
}
}
@Test
fun `present - reserveSpace becomes true once we get the first typing notification with room members`() = runTest {
val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2)
val room = FakeMatrixRoom()
val presenter = createPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.typingMembers).isEmpty()
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
skipItems(1)
val updatedTypingState = awaitItem()
assertThat(updatedTypingState.reserveSpace).isTrue()
// User stops typing
room.givenRoomTypingMembers(emptyList())
// Is still true for all future events
val futureEvents = cancelAndConsumeRemainingEvents()
for (event in futureEvents) {
if (event is Event.Item) {
assertThat(event.value.reserveSpace).isTrue()
}
}
}
}
private fun createPresenter(
matrixRoom: MatrixRoom = FakeMatrixRoom().apply {
givenRoomInfo(aRoomInfo(id = roomId.value, name = ""))
},
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(
isRenderTypingNotificationsEnabled = true
),
) = TypingNotificationPresenter(
room = matrixRoom,
sessionPreferencesStore = sessionPreferencesStore,
)
private fun createDefaultRoomMember(
userId: UserId,
) = RoomMember(
userId = userId,
displayName = null,
avatarUrl = null,
membership = RoomMembershipState.JOIN,
isNameAmbiguous = false,
powerLevel = 0,
normalizedPowerLevel = 0,
isIgnored = false,
)
private fun createKnownRoomMember(
userId: UserId,
) = RoomMember(
userId = userId,
displayName = "Alice Doe",
avatarUrl = "an_avatar_url",
membership = RoomMembershipState.JOIN,
isNameAmbiguous = true,
powerLevel = 0,
normalizedPowerLevel = 0,
isIgnored = false,
)
}

View file

@ -37,7 +37,6 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@Composable
fun ConnectivityIndicatorView(
isOnline: Boolean,
modifier: Modifier = Modifier,
) {
val isIndicatorVisible = remember { MutableTransitionState(!isOnline) }.apply { targetState = !isOnline }
val isStatusBarPaddingVisible = remember { MutableTransitionState(isOnline) }.apply { targetState = isOnline }
@ -48,7 +47,7 @@ fun ConnectivityIndicatorView(
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Indicator(modifier)
Indicator()
}
// Show missing status bar padding when the indicator is not visible
@ -57,7 +56,7 @@ fun ConnectivityIndicatorView(
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
StatusBarPaddingSpacer(modifier)
StatusBarPaddingSpacer()
}
}

View file

@ -54,7 +54,7 @@ internal fun Indicator(
) {
val tint = MaterialTheme.colorScheme.primary
Icon(
imageVector = CompoundIcons.Offline,
imageVector = CompoundIcons.Offline(),
contentDescription = null,
tint = tint,
modifier = Modifier.size(16.sp.toDp()),

View file

@ -93,10 +93,9 @@ fun OnBoardingView(
private fun OnBoardingContent(
state: OnBoardingState,
onOpenDeveloperSettings: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier = Modifier.fillMaxSize(),
@ -143,7 +142,7 @@ private fun OnBoardingContent(
onClick = onOpenDeveloperSettings,
) {
Icon(
imageVector = CompoundIcons.SettingsSolid,
imageVector = CompoundIcons.SettingsSolid(),
contentDescription = stringResource(CommonStrings.common_settings)
)
}
@ -158,9 +157,8 @@ private fun OnBoardingButtons(
onSignIn: () -> Unit,
onCreateAccount: () -> Unit,
onReportProblem: () -> Unit,
modifier: Modifier = Modifier,
) {
ButtonColumnMolecule(modifier = modifier) {
ButtonColumnMolecule {
val signInButtonStringRes = if (state.canLoginWithQrCode || state.canCreateAccount) {
R.string.screen_onboarding_sign_in_manually
} else {

View file

@ -143,21 +143,19 @@ fun PollContentView(
private fun PollTitle(
title: String,
isPollEnded: Boolean,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (isPollEnded) {
Icon(
imageVector = CompoundIcons.PollsEnd,
imageVector = CompoundIcons.PollsEnd(),
contentDescription = stringResource(id = CommonStrings.a11y_poll_end),
modifier = Modifier.size(22.dp)
)
} else {
Icon(
imageVector = CompoundIcons.Polls,
imageVector = CompoundIcons.Polls(),
contentDescription = stringResource(id = CommonStrings.a11y_poll),
modifier = Modifier.size(22.dp)
)
@ -173,10 +171,9 @@ private fun PollTitle(
private fun PollAnswers(
answerItems: ImmutableList<PollAnswerItem>,
onAnswerSelected: (PollAnswer) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.selectableGroup(),
modifier = Modifier.selectableGroup(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
answerItems.forEach {
@ -197,10 +194,9 @@ private fun PollAnswers(
@Composable
private fun ColumnScope.DisclosedPollBottomNotice(
votesCount: Int,
modifier: Modifier = Modifier
) {
Text(
modifier = modifier.align(Alignment.End),
modifier = Modifier.align(Alignment.End),
style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textSecondary,
text = stringResource(CommonStrings.common_poll_total_votes, votesCount),
@ -208,11 +204,9 @@ private fun ColumnScope.DisclosedPollBottomNotice(
}
@Composable
private fun ColumnScope.UndisclosedPollBottomNotice(
modifier: Modifier = Modifier
) {
private fun ColumnScope.UndisclosedPollBottomNotice() {
Text(
modifier = modifier
modifier = Modifier
.align(Alignment.Start)
.padding(start = 34.dp),
style = ElementTheme.typography.fontBodyXsRegular,

View file

@ -162,7 +162,7 @@ fun CreatePollView(
},
trailingContent = ListItemContent.Custom {
Icon(
imageVector = CompoundIcons.Delete,
imageVector = CompoundIcons.Delete(),
contentDescription = null,
modifier = Modifier.clickable(answer.canDelete) {
state.eventSink(CreatePollEvents.RemoveAnswer(index))

View file

@ -55,6 +55,7 @@ dependencies {
implementation(projects.features.analytics.api)
implementation(projects.features.ftue.api)
implementation(projects.features.logout.api)
implementation(projects.features.roomlist.api)
implementation(projects.services.analytics.api)
implementation(projects.services.toolbox.api)
implementation(libs.datetime)

View file

@ -92,9 +92,8 @@ fun DeveloperSettingsView(
@Composable
private fun ElementCallCategory(
state: DeveloperSettingsState,
modifier: Modifier = Modifier,
) {
PreferenceCategory(modifier = modifier, title = "Element Call", showDivider = true) {
PreferenceCategory(title = "Element Call", showDivider = true) {
val callUrlState = state.customElementCallBaseUrlState
fun isUsingDefaultUrl(value: String?): Boolean {
return value.isNullOrEmpty() || value == callUrlState.defaultUrl
@ -120,14 +119,12 @@ private fun ElementCallCategory(
@Composable
private fun FeatureListContent(
state: DeveloperSettingsState,
modifier: Modifier = Modifier
) {
fun onFeatureEnabled(feature: FeatureUiModel, isEnabled: Boolean) {
state.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(feature, isEnabled))
}
FeatureListView(
modifier = modifier,
features = state.features,
onCheckedChange = ::onFeatureEnabled,
)

View file

@ -90,7 +90,7 @@ fun ConfigureTracingView(
onClick = { showMenu = !showMenu }
) {
Icon(
imageVector = CompoundIcons.OverflowVertical,
imageVector = CompoundIcons.OverflowVertical(),
tint = ElementTheme.materialColors.secondary,
contentDescription = null,
)
@ -107,7 +107,7 @@ fun ConfigureTracingView(
text = { Text("Reset to default") },
leadingIcon = {
Icon(
imageVector = CompoundIcons.Delete,
imageVector = CompoundIcons.Delete(),
tint = ElementTheme.materialColors.secondary,
contentDescription = null,
)
@ -141,14 +141,12 @@ fun ConfigureTracingView(
@Composable
private fun CrateListContent(
state: ConfigureTracingState,
modifier: Modifier = Modifier
) {
fun onLogLevelChange(target: Target, logLevel: LogLevel) {
state.eventSink(ConfigureTracingEvents.UpdateFilter(target, logLevel))
}
TargetAndLogLevelListView(
modifier = modifier,
data = state.targetsToLogLevel,
onLogLevelChange = ::onLogLevelChange,
)
@ -158,11 +156,8 @@ private fun CrateListContent(
private fun TargetAndLogLevelListView(
data: ImmutableMap<Target, LogLevel>,
onLogLevelChange: (Target, LogLevel) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
) {
Column {
data.forEach { item ->
fun onLogLevelChange(logLevel: LogLevel) {
onLogLevelChange(item.key, logLevel)
@ -182,10 +177,8 @@ private fun TargetAndLogLevelView(
target: Target,
logLevel: LogLevel,
onLogLevelChange: (LogLevel) -> Unit,
modifier: Modifier = Modifier
) {
ListItem(
modifier = modifier,
headlineContent = { Text(text = target.filter.takeIf { it.isNotEmpty() } ?: "(common)") },
trailingContent = ListItemContent.Custom {
LogLevelDropdownMenu(
@ -200,10 +193,9 @@ private fun TargetAndLogLevelView(
private fun LogLevelDropdownMenu(
logLevel: LogLevel,
onLogLevelChange: (LogLevel) -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
Box(modifier = modifier) {
Box {
DropdownMenuItem(
modifier = Modifier.widthIn(max = 120.dp),
text = { Text(text = logLevel.filter) },
@ -211,7 +203,7 @@ private fun LogLevelDropdownMenu(
trailingIcon = {
Icon(
modifier = Modifier.rotate(if (expanded) 180f else 0f),
imageVector = CompoundIcons.ChevronDown,
imageVector = CompoundIcons.ChevronDown(),
contentDescription = null,
)
},

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