Merge branch 'develop' into feature/fga/knock_requests_sdk
This commit is contained in:
commit
01f7fc20f4
391 changed files with 6199 additions and 1991 deletions
|
|
@ -4,15 +4,26 @@
|
|||
<string name="screen_knock_requests_list_accept_all_alert_description">"Opravdu chcete přijmout všechny žádosti o vstup?"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_alert_title">"Přijmout všechny požadavky"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_button_title">"Přijmout vše"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_failed_alert_description">"Nemohli jsme přijmout všechny žádosti. Chcete to zkusit znovu?"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_failed_alert_title">"Nepodařilo se přijmout všechny žádosti"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_loading_title">"Přijímání všech žádostí o vstup"</string>
|
||||
<string name="screen_knock_requests_list_accept_failed_alert_description">"Tuto žádost jsme nemohli přijmout. Chcete to zkusit znovu?"</string>
|
||||
<string name="screen_knock_requests_list_accept_failed_alert_title">"Žádost se nepodařilo přijmout"</string>
|
||||
<string name="screen_knock_requests_list_accept_loading_title">"Přijímání žádosti o vstup"</string>
|
||||
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Ano, odmítnout a vykázat"</string>
|
||||
<string name="screen_knock_requests_list_ban_alert_description">"Opravdu chcete odmítnout a vykázat %1$s? Tento uživatel nebude moci znovu požádat o vstup do této místnosti."</string>
|
||||
<string name="screen_knock_requests_list_ban_alert_title">"Odmítnout a zakázat vstup"</string>
|
||||
<string name="screen_knock_requests_list_ban_loading_title">"Odmítání vstupu a vykázání"</string>
|
||||
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Ano, odmítnout"</string>
|
||||
<string name="screen_knock_requests_list_decline_alert_description">"Opravdu chcete odmítnout %1$s žádost o vstup do této místnosti?"</string>
|
||||
<string name="screen_knock_requests_list_decline_alert_title">"Odmítnout vstup"</string>
|
||||
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Odmítnout a vykázat"</string>
|
||||
<string name="screen_knock_requests_list_decline_failed_alert_description">"Tuto žádost jsme nemohli odmítnout. Chcete to zkusit znovu?"</string>
|
||||
<string name="screen_knock_requests_list_decline_failed_alert_title">"Žádost se nepodařilo odmítnout"</string>
|
||||
<string name="screen_knock_requests_list_decline_loading_title">"Odmítání žádosti o vstup"</string>
|
||||
<string name="screen_knock_requests_list_empty_state_description">"Když někdo požádá o vstup do místnosti, uvidíte jeho žádost zde."</string>
|
||||
<string name="screen_knock_requests_list_empty_state_title">"Žádná čekající žádost o vstup"</string>
|
||||
<string name="screen_knock_requests_list_initial_loading_title">"Načítání žádostí o vstup…"</string>
|
||||
<string name="screen_knock_requests_list_title">"Žádosti o vstup"</string>
|
||||
<plurals name="screen_room_multiple_knock_requests_title">
|
||||
<item quantity="one">"%1$s +%2$d další chce vstoupit do této místnosti"</item>
|
||||
|
|
|
|||
|
|
@ -4,13 +4,17 @@
|
|||
<string name="screen_knock_requests_list_accept_all_alert_description">"Σίγουρα θες να αποδεχτείς όλα τα αιτήματα συμμετοχής;"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_alert_title">"Αποδοχή όλων των αιτημάτων"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_button_title">"Αποδοχή όλων"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_loading_title">"Αποδοχή όλων των αιτημάτων συμμετοχής"</string>
|
||||
<string name="screen_knock_requests_list_accept_loading_title">"Γίνεται αποδοχή αιτήματος συμμετοχής"</string>
|
||||
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Ναι, απόρριψη και αποκλεισμός"</string>
|
||||
<string name="screen_knock_requests_list_ban_alert_description">"Σίγουρα θες να απορρίψειε και να αποκλείσεις τον χρήστη %1$s; Αυτός ο χρήστης δεν θα μπορεί να ζητήσει πρόσβαση για να συμμετάσχει ξανά σε αυτό το δωμάτιο."</string>
|
||||
<string name="screen_knock_requests_list_ban_alert_title">"Απόρριψη και αποκλεισμός πρόσβασης"</string>
|
||||
<string name="screen_knock_requests_list_ban_loading_title">"Γίνεται απόρριψη και αποκλεισμός πρόσβασης"</string>
|
||||
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Ναι, απόρριψη"</string>
|
||||
<string name="screen_knock_requests_list_decline_alert_description">"Σίγουρα θες να απορρίψεις το αίτημα του χρήστη %1$s να συμμετάσχει στο δωμάτιο;"</string>
|
||||
<string name="screen_knock_requests_list_decline_alert_title">"Απόρριψη πρόσβασης"</string>
|
||||
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Απόρριψη και αποκλεισμός"</string>
|
||||
<string name="screen_knock_requests_list_decline_loading_title">"Γίνεται απόρριψη αιτήματος συμμετοχής"</string>
|
||||
<string name="screen_knock_requests_list_empty_state_description">"Όταν κάποιος θα ζητήσει να συμμετάσχει στο δωμάτιο, θα μπορείς να δεις το αίτημά του εδώ."</string>
|
||||
<string name="screen_knock_requests_list_empty_state_title">"Δεν υπάρχει εκκρεμές αίτημα συμμετοχής"</string>
|
||||
<string name="screen_knock_requests_list_title">"Αιτήματα συμμετοχής"</string>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_knock_requests_list_accept_all_alert_confirm_button_title">"Kyllä, hyväksy kaikki"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_alert_description">"Haluatko varmasti hyväksyä kaikki liittymispyynnöt?"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_alert_title">"Hyväksy kaikki pyynnöt"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_button_title">"Hyväksy kaikki"</string>
|
||||
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Kyllä, hylkää ja anna porttikielto"</string>
|
||||
<string name="screen_knock_requests_list_ban_alert_description">"Haluatko varmasti hylätä käyttäjän %1$s pyynnön liittyä huoneeseen ja antaa hänelle porttikiellon? Hän ei voi enää pyytää lupaa liittyä tähän huoneeseen."</string>
|
||||
<string name="screen_knock_requests_list_ban_alert_title">"Hylkää ja anna porttikielto"</string>
|
||||
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Kyllä, hylkää"</string>
|
||||
<string name="screen_knock_requests_list_decline_alert_description">"Haluatko varmasti hylätä käyttäjän %1$s pyynnön liittyä tähän huoneeseen?"</string>
|
||||
<string name="screen_knock_requests_list_decline_alert_title">"Hylkää pyyntö"</string>
|
||||
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Hylkää ja anna porttikielto"</string>
|
||||
<string name="screen_knock_requests_list_empty_state_description">"Kun joku pyytää liittyä huoneeseen, näet hänen pyyntönsä täällä."</string>
|
||||
<string name="screen_knock_requests_list_empty_state_title">"Ei odottavia liittymispyyntöjä"</string>
|
||||
<string name="screen_knock_requests_list_title">"Liittymispyynnöt"</string>
|
||||
<plurals name="screen_room_multiple_knock_requests_title">
|
||||
<item quantity="one">"%1$s +%2$d muu haluavat liittyä tähän huoneeseen"</item>
|
||||
<item quantity="other">"%1$s +%2$d muuta haluavat liittyä tähän huoneeseen"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Näytä kaikki"</string>
|
||||
<string name="screen_room_single_knock_request_accept_button_title">"Hyväksy"</string>
|
||||
<string name="screen_room_single_knock_request_title">"%1$s haluaa liittyä tähän huoneeseen"</string>
|
||||
<string name="screen_room_single_knock_request_view_button_title">"Näytä"</string>
|
||||
</resources>
|
||||
|
|
@ -4,15 +4,26 @@
|
|||
<string name="screen_knock_requests_list_accept_all_alert_description">"Êtes-vous sûr de vouloir accepter toutes les demandes pour rejoindre le salon ?"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_alert_title">"Tout accepter"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_button_title">"Tout accepter"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_failed_alert_description">"Toutes les demandes n’ont pas pu être acceptées. Voulez-vous réessayer ?"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_failed_alert_title">"Toutes les demandes n’ont pas été acceptées"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_loading_title">"Accepter toutes les demandes à rejoindre"</string>
|
||||
<string name="screen_knock_requests_list_accept_failed_alert_description">"La demande n’a pas pu être acceptée. Voulez-vous réessayer ?"</string>
|
||||
<string name="screen_knock_requests_list_accept_failed_alert_title">"Impossible d’accepter la demande"</string>
|
||||
<string name="screen_knock_requests_list_accept_loading_title">"Accepter la demande à rejoindre"</string>
|
||||
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Oui, rejeter et bannir"</string>
|
||||
<string name="screen_knock_requests_list_ban_alert_description">"Êtes-vous sûr de vouloir rejeter la demande et bannir %1$s ? Cet utilisateur ne pourra pas demander à nouveau à rejoindre ce salon."</string>
|
||||
<string name="screen_knock_requests_list_ban_alert_title">"Refuser et interdire l’accès"</string>
|
||||
<string name="screen_knock_requests_list_ban_loading_title">"En cours de traitement…"</string>
|
||||
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Oui, refuser"</string>
|
||||
<string name="screen_knock_requests_list_decline_alert_description">"Êtes-vous sûr de vouloir refuser la demande de %1$s à rejoindre le salon ?"</string>
|
||||
<string name="screen_knock_requests_list_decline_alert_title">"Refuser l’accès"</string>
|
||||
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Refuser et bannir"</string>
|
||||
<string name="screen_knock_requests_list_decline_failed_alert_description">"Nous n’avons pas pu refuser cette demande. Voulez-vous réessayer ?"</string>
|
||||
<string name="screen_knock_requests_list_decline_failed_alert_title">"Echec"</string>
|
||||
<string name="screen_knock_requests_list_decline_loading_title">"Traitement en cours…"</string>
|
||||
<string name="screen_knock_requests_list_empty_state_description">"Lorsque quelqu’un demandera à rejoindre le salon, vous pourrez voir sa demande ici."</string>
|
||||
<string name="screen_knock_requests_list_empty_state_title">"Personne ne demande à rejoindre le salon"</string>
|
||||
<string name="screen_knock_requests_list_initial_loading_title">"Chargement…"</string>
|
||||
<string name="screen_knock_requests_list_title">"Demandes en attente"</string>
|
||||
<plurals name="screen_room_multiple_knock_requests_title">
|
||||
<item quantity="one">"%1$s et %2$d autre personne souhaitent rejoindre ce salon"</item>
|
||||
|
|
|
|||
|
|
@ -4,15 +4,26 @@
|
|||
<string name="screen_knock_requests_list_accept_all_alert_description">"Biztos, hogy elfogadja az összes csatlakozási kérelmet?"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_alert_title">"Minden kérés elfogadása"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_button_title">"Összes elfogadása"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_failed_alert_description">"Nem sikerült az összes kérés fogadása. Újra megpróbálja?"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_failed_alert_title">"Nem sikerült az összes kérés elfogadása"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_loading_title">"Összes csatlakozási kérés elfogadása"</string>
|
||||
<string name="screen_knock_requests_list_accept_failed_alert_description">"Nem sikerült elfogadni a kérést. Megpróbálja újra?"</string>
|
||||
<string name="screen_knock_requests_list_accept_failed_alert_title">"Nem sikerült elfogadni a kérést"</string>
|
||||
<string name="screen_knock_requests_list_accept_loading_title">"Csatlakozási kérés elfogadása"</string>
|
||||
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Igen, elutasítás és kitiltás"</string>
|
||||
<string name="screen_knock_requests_list_ban_alert_description">"Biztos, hogy elutasítja %1$s kérését és ki is tiltja? Többé nem fogja tudni azt kérni, hogy csatlakozhasson ehhez a szobához."</string>
|
||||
<string name="screen_knock_requests_list_ban_alert_title">"A hozzáférés elutasítása és kitiltás"</string>
|
||||
<string name="screen_knock_requests_list_ban_loading_title">"A hozzáférés megtagadása és kitiltás"</string>
|
||||
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Igen, elutasítás"</string>
|
||||
<string name="screen_knock_requests_list_decline_alert_description">"Biztos, hogy elutasítja %1$s kérését, hogy csatlakozzon a szobához?"</string>
|
||||
<string name="screen_knock_requests_list_decline_alert_title">"Hozzáférés elutasítása"</string>
|
||||
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Elutasítás és kitiltás"</string>
|
||||
<string name="screen_knock_requests_list_decline_failed_alert_description">"Nem sikerült elutasítani a kérést. Megpróbálja újra?"</string>
|
||||
<string name="screen_knock_requests_list_decline_failed_alert_title">"Nem sikerült elutasítani a kérést"</string>
|
||||
<string name="screen_knock_requests_list_decline_loading_title">"Csatlakozási kérés elutasítása"</string>
|
||||
<string name="screen_knock_requests_list_empty_state_description">"Ha valaki csatlakozni kíván a szobához, itt láthatja a kérését."</string>
|
||||
<string name="screen_knock_requests_list_empty_state_title">"Nincs függőben lévő csatlakozási kérelem"</string>
|
||||
<string name="screen_knock_requests_list_initial_loading_title">"Csatlakozási kérések betöltése…"</string>
|
||||
<string name="screen_knock_requests_list_title">"Csatlakozási kérelmek"</string>
|
||||
<plurals name="screen_room_multiple_knock_requests_title">
|
||||
<item quantity="one">"%1$s és még %2$d felhasználó szeretne csatlakozni ehhez a szobához"</item>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.licenses.impl.list
|
||||
|
||||
sealed interface DependencyLicensesListEvent {
|
||||
data class SetFilter(val filter: String) : DependencyLicensesListEvent
|
||||
}
|
||||
|
|
@ -29,6 +29,10 @@ class DependencyLicensesListPresenter @Inject constructor(
|
|||
var licenses by remember {
|
||||
mutableStateOf<AsyncData<ImmutableList<DependencyLicenseItem>>>(AsyncData.Loading())
|
||||
}
|
||||
var filteredLicenses by remember {
|
||||
mutableStateOf<AsyncData<ImmutableList<DependencyLicenseItem>>>(AsyncData.Loading())
|
||||
}
|
||||
var filter by remember { mutableStateOf("") }
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching {
|
||||
licenses = AsyncData.Success(licensesProvider.provides().toPersistentList())
|
||||
|
|
@ -36,6 +40,32 @@ class DependencyLicensesListPresenter @Inject constructor(
|
|||
licenses = AsyncData.Failure(it)
|
||||
}
|
||||
}
|
||||
return DependencyLicensesListState(licenses = licenses)
|
||||
LaunchedEffect(filter, licenses.dataOrNull()) {
|
||||
val data = licenses.dataOrNull()
|
||||
val safeFilter = filter.trim()
|
||||
if (data != null && safeFilter.isNotEmpty()) {
|
||||
filteredLicenses = AsyncData.Success(data.filter {
|
||||
it.safeName.contains(safeFilter, ignoreCase = true) ||
|
||||
it.groupId.contains(safeFilter, ignoreCase = true) ||
|
||||
it.artifactId.contains(safeFilter, ignoreCase = true)
|
||||
}.toPersistentList())
|
||||
} else {
|
||||
filteredLicenses = licenses
|
||||
}
|
||||
}
|
||||
|
||||
fun handleEvent(dependencyLicensesListEvent: DependencyLicensesListEvent) {
|
||||
when (dependencyLicensesListEvent) {
|
||||
is DependencyLicensesListEvent.SetFilter -> {
|
||||
filter = dependencyLicensesListEvent.filter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return DependencyLicensesListState(
|
||||
licenses = filteredLicenses,
|
||||
filter = filter,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,4 +13,6 @@ import kotlinx.collections.immutable.ImmutableList
|
|||
|
||||
data class DependencyLicensesListState(
|
||||
val licenses: AsyncData<ImmutableList<DependencyLicenseItem>>,
|
||||
val filter: String,
|
||||
val eventSink: (DependencyLicensesListEvent) -> Unit,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,28 +11,49 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
|||
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
|
||||
import io.element.android.features.licenses.impl.model.License
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
open class DependencyLicensesListStateProvider : PreviewParameterProvider<DependencyLicensesListState> {
|
||||
override val values: Sequence<DependencyLicensesListState>
|
||||
get() = sequenceOf(
|
||||
DependencyLicensesListState(
|
||||
aDependencyLicensesListState(
|
||||
licenses = AsyncData.Loading()
|
||||
),
|
||||
DependencyLicensesListState(
|
||||
aDependencyLicensesListState(
|
||||
licenses = AsyncData.Failure(Exception("Failed to load licenses"))
|
||||
),
|
||||
DependencyLicensesListState(
|
||||
aDependencyLicensesListState(
|
||||
licenses = AsyncData.Success(
|
||||
persistentListOf(
|
||||
aDependencyLicenseItem(),
|
||||
aDependencyLicenseItem(name = null),
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
aDependencyLicensesListState(
|
||||
licenses = AsyncData.Success(
|
||||
persistentListOf(
|
||||
aDependencyLicenseItem(),
|
||||
aDependencyLicenseItem(name = null),
|
||||
)
|
||||
),
|
||||
filter = "a filter",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun aDependencyLicensesListState(
|
||||
licenses: AsyncData<ImmutableList<DependencyLicenseItem>>,
|
||||
filter: String = "",
|
||||
): DependencyLicensesListState {
|
||||
return DependencyLicensesListState(
|
||||
licenses = licenses,
|
||||
filter = filter,
|
||||
eventSink = {},
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aDependencyLicenseItem(
|
||||
name: String? = "A dependency",
|
||||
) = DependencyLicenseItem(
|
||||
|
|
|
|||
|
|
@ -7,31 +7,36 @@
|
|||
|
||||
package io.element.android.features.licenses.impl.list
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun DependencyLicensesListView(
|
||||
state: DependencyLicensesListState,
|
||||
|
|
@ -48,48 +53,64 @@ fun DependencyLicensesListView(
|
|||
)
|
||||
},
|
||||
) { contentPadding ->
|
||||
LazyColumn(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(contentPadding)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
when (state.licenses) {
|
||||
is AsyncData.Failure -> item {
|
||||
Text(
|
||||
text = stringResource(CommonStrings.common_error),
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
AsyncData.Uninitialized,
|
||||
is AsyncData.Loading -> item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 64.dp)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
if (state.licenses.isSuccess()) {
|
||||
// Search field
|
||||
OutlinedTextField(
|
||||
value = state.filter,
|
||||
onValueChange = { state.eventSink(DependencyLicensesListEvent.SetFilter(it)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Search(),
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
LazyColumn {
|
||||
when (state.licenses) {
|
||||
is AsyncData.Failure -> item {
|
||||
Text(
|
||||
text = stringResource(CommonStrings.common_error),
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
is AsyncData.Success -> items(state.licenses.data) { license ->
|
||||
ListItem(
|
||||
headlineContent = { Text(license.safeName) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
buildString {
|
||||
append(license.groupId)
|
||||
append(":")
|
||||
append(license.artifactId)
|
||||
append(":")
|
||||
append(license.version)
|
||||
}
|
||||
AsyncData.Uninitialized,
|
||||
is AsyncData.Loading -> item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 64.dp)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
onOpenLicense(license)
|
||||
}
|
||||
)
|
||||
}
|
||||
is AsyncData.Success -> items(state.licenses.data) { license ->
|
||||
ListItem(
|
||||
headlineContent = { Text(license.safeName) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
buildString {
|
||||
append(license.groupId)
|
||||
append(":")
|
||||
append(license.artifactId)
|
||||
append(":")
|
||||
append(license.version)
|
||||
}
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
onOpenLicense(license)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ class DependencyLicensesListPresenterTest {
|
|||
val finalState = awaitItem()
|
||||
assertThat(finalState.licenses.isSuccess()).isTrue()
|
||||
assertThat(finalState.licenses.dataOrNull()).isEmpty()
|
||||
assertThat(finalState.filter).isEqualTo("")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +55,40 @@ class DependencyLicensesListPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state, one license, set filter`() = runTest {
|
||||
val anItem = aDependencyLicenseItem()
|
||||
val presenter = createPresenter {
|
||||
listOf(anItem)
|
||||
}
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.licenses).isInstanceOf(AsyncData.Loading::class.java)
|
||||
val loadedState = awaitItem()
|
||||
assertThat(loadedState.licenses.isSuccess()).isTrue()
|
||||
assertThat(loadedState.licenses.dataOrNull()!!.size).isEqualTo(1)
|
||||
loadedState.eventSink(DependencyLicensesListEvent.SetFilter("dep"))
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.licenses.dataOrNull()!!.size).isEqualTo(1)
|
||||
assertThat(state.filter).isEqualTo("dep")
|
||||
}
|
||||
loadedState.eventSink(DependencyLicensesListEvent.SetFilter("bleh"))
|
||||
skipItems(1)
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.licenses.dataOrNull()!!.size).isEqualTo(0)
|
||||
assertThat(state.filter).isEqualTo("bleh")
|
||||
}
|
||||
loadedState.eventSink(DependencyLicensesListEvent.SetFilter(""))
|
||||
skipItems(1)
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.licenses.dataOrNull()!!.size).isEqualTo(1)
|
||||
assertThat(state.filter).isEqualTo("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPresenter(
|
||||
provideResult: () -> List<DependencyLicenseItem>
|
||||
) = DependencyLicensesListPresenter(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
<string name="screen_app_lock_biometric_authentication">"biometrinen tunnistus"</string>
|
||||
<string name="screen_app_lock_biometric_unlock">"biometrinen tunnistus"</string>
|
||||
<string name="screen_app_lock_biometric_unlock_title_android">"Avaa biometrisellä"</string>
|
||||
<string name="screen_app_lock_confirm_biometric_authentication_android">"Vahvista biometrinen tunniste"</string>
|
||||
<string name="screen_app_lock_forgot_pin">"Unohtuiko PIN-koodi?"</string>
|
||||
<string name="screen_app_lock_settings_change_pin">"Vaihda PIN-koodi"</string>
|
||||
<string name="screen_app_lock_settings_enable_biometric_unlock">"Salli biometrinen tunnistus"</string>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ dependencies {
|
|||
implementation(projects.libraries.permissions.api)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.roomselect.api)
|
||||
implementation(projects.libraries.voiceplayer.api)
|
||||
implementation(projects.libraries.voicerecorder.api)
|
||||
implementation(projects.libraries.mediaplayer.api)
|
||||
implementation(projects.libraries.uiUtils)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
|
||||
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.poll.api.create.CreatePollEntryPoint
|
||||
import io.element.android.features.poll.api.create.CreatePollMode
|
||||
import io.element.android.libraries.architecture.BackstackWithOverlayBox
|
||||
|
|
@ -55,6 +56,8 @@ import io.element.android.libraries.architecture.createNode
|
|||
import io.element.android.libraries.architecture.overlay.Overlay
|
||||
import io.element.android.libraries.architecture.overlay.operation.hide
|
||||
import io.element.android.libraries.architecture.overlay.operation.show
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
|
@ -97,6 +100,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider,
|
||||
private val timelineController: TimelineController,
|
||||
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
|
||||
private val dateFormatter: DateFormatter,
|
||||
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = plugins.filterIsInstance<MessagesEntryPoint.Params>().first().initialTarget.toNavTarget(),
|
||||
|
|
@ -436,7 +440,15 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
senderId = event.senderId,
|
||||
senderName = event.safeSenderName,
|
||||
senderAvatar = event.senderAvatar.url,
|
||||
dateSent = event.sentTime,
|
||||
dateSent = dateFormatter.format(
|
||||
event.sentTimeMillis,
|
||||
mode = DateFormatterMode.Day,
|
||||
),
|
||||
dateSentFull = dateFormatter.format(
|
||||
timestamp = event.sentTimeMillis,
|
||||
mode = DateFormatterMode.Full,
|
||||
),
|
||||
waveform = (content as? TimelineItemVoiceContent)?.waveform,
|
||||
),
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = thumbnailSource,
|
||||
|
|
|
|||
|
|
@ -274,7 +274,8 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
TimelineItemAction.CopyCaption -> handleCopyCaption(targetEvent)
|
||||
TimelineItemAction.CopyLink -> handleCopyLink(targetEvent)
|
||||
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
|
||||
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
|
||||
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
|
||||
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState)
|
||||
TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ import io.element.android.features.messages.impl.timeline.model.event.canBeCopie
|
|||
import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded
|
||||
import io.element.android.features.messages.impl.timeline.model.event.canReact
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
|
|
@ -64,6 +66,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
|
|||
private val room: MatrixRoom,
|
||||
private val userSendFailureFactory: VerifiedUserSendFailureFactory,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val dateFormatter: DateFormatter,
|
||||
) : ActionListPresenter {
|
||||
@AssistedFactory
|
||||
@ContributesBinding(RoomScope::class)
|
||||
|
|
@ -131,6 +134,11 @@ class DefaultActionListPresenter @AssistedInject constructor(
|
|||
if (actions.isNotEmpty() || displayEmojiReactions || verifiedUserSendFailure != VerifiedUserSendFailure.None) {
|
||||
target.value = ActionListState.Target.Success(
|
||||
event = timelineItem,
|
||||
sentTimeFull = dateFormatter.format(
|
||||
timelineItem.sentTimeMillis,
|
||||
DateFormatterMode.Full,
|
||||
useRelative = true,
|
||||
),
|
||||
displayEmojiReactions = displayEmojiReactions,
|
||||
verifiedUserSendFailure = verifiedUserSendFailure,
|
||||
actions = actions.toImmutableList()
|
||||
|
|
@ -170,6 +178,8 @@ class DefaultActionListPresenter @AssistedInject constructor(
|
|||
add(TimelineItemAction.EditCaption)
|
||||
add(TimelineItemAction.RemoveCaption)
|
||||
}
|
||||
} else if (timelineItem.content is TimelineItemPollContent) {
|
||||
add(TimelineItemAction.EditPoll)
|
||||
} else {
|
||||
add(TimelineItemAction.Edit)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ data class ActionListState(
|
|||
data class Loading(val event: TimelineItem.Event) : Target
|
||||
data class Success(
|
||||
val event: TimelineItem.Event,
|
||||
val sentTimeFull: String,
|
||||
val displayEmojiReactions: Boolean,
|
||||
val verifiedUserSendFailure: VerifiedUserSendFailure,
|
||||
val actions: ImmutableList<TimelineItemAction>,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
event = aTimelineItemEvent(
|
||||
timelineItemReactions = reactionsState
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(),
|
||||
|
|
@ -49,6 +50,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
displayNameAmbiguous = true,
|
||||
timelineItemReactions = reactionsState,
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(
|
||||
|
|
@ -62,6 +64,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
content = aTimelineItemVideoContent(),
|
||||
timelineItemReactions = reactionsState
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(
|
||||
|
|
@ -75,6 +78,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
content = aTimelineItemFileContent(),
|
||||
timelineItemReactions = reactionsState
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(
|
||||
|
|
@ -88,6 +92,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
content = aTimelineItemAudioContent(),
|
||||
timelineItemReactions = reactionsState
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(
|
||||
|
|
@ -101,6 +106,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
content = aTimelineItemVoiceContent(caption = null),
|
||||
timelineItemReactions = reactionsState
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(
|
||||
|
|
@ -114,6 +120,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
content = aTimelineItemLocationContent(),
|
||||
timelineItemReactions = reactionsState
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(),
|
||||
|
|
@ -125,6 +132,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
content = aTimelineItemLocationContent(),
|
||||
timelineItemReactions = reactionsState
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = false,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(),
|
||||
|
|
@ -136,6 +144,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
content = aTimelineItemPollContent(),
|
||||
timelineItemReactions = reactionsState
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = false,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemPollActionList(),
|
||||
|
|
@ -147,6 +156,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
timelineItemReactions = reactionsState,
|
||||
messageShield = MessageShield.UnknownDevice(isCritical = true)
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(),
|
||||
|
|
@ -155,6 +165,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
anActionListState(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = anUnsignedDeviceSendFailure(),
|
||||
actions = aTimelineItemActionList(),
|
||||
|
|
@ -192,6 +203,7 @@ fun aTimelineItemActionList(
|
|||
fun aTimelineItemPollActionList(): ImmutableList<TimelineItemAction> {
|
||||
return setOf(
|
||||
TimelineItemAction.EndPoll,
|
||||
TimelineItemAction.EditPoll,
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyLink,
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@ private fun ActionListViewContent(
|
|||
Column {
|
||||
MessageSummary(
|
||||
event = target.event,
|
||||
sentTimeFull = target.sentTimeFull,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
|
|
@ -245,7 +246,11 @@ private fun ActionListViewContent(
|
|||
|
||||
@Suppress("MultipleEmitters") // False positive
|
||||
@Composable
|
||||
private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modifier) {
|
||||
private fun MessageSummary(
|
||||
event: TimelineItem.Event,
|
||||
sentTimeFull: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val content: @Composable () -> Unit
|
||||
val icon: @Composable () -> Unit = { Avatar(avatarData = event.senderAvatar.copy(size = AvatarSize.MessageActionSender)) }
|
||||
val contentStyle = ElementTheme.typography.fontBodyMdRegular.copy(color = MaterialTheme.colorScheme.secondary)
|
||||
|
|
@ -300,20 +305,23 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
|
|||
icon()
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
SenderName(
|
||||
senderId = event.senderId,
|
||||
senderProfile = event.senderProfile,
|
||||
senderNameMode = SenderNameMode.ActionList,
|
||||
)
|
||||
Row {
|
||||
SenderName(
|
||||
modifier = Modifier.weight(1f),
|
||||
senderId = event.senderId,
|
||||
senderProfile = event.senderProfile,
|
||||
senderNameMode = SenderNameMode.ActionList,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(
|
||||
text = sentTimeFull,
|
||||
style = ElementTheme.typography.fontBodyXsRegular,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
content()
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(
|
||||
event.sentTime,
|
||||
style = ElementTheme.typography.fontBodyXsRegular,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,30 +11,30 @@ import androidx.annotation.DrawableRes
|
|||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Immutable
|
||||
sealed class TimelineItemAction(
|
||||
enum class TimelineItemAction(
|
||||
@StringRes val titleRes: Int,
|
||||
@DrawableRes val icon: Int,
|
||||
val destructive: Boolean = false
|
||||
) {
|
||||
data object ViewInTimeline : TimelineItemAction(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on)
|
||||
data object Forward : TimelineItemAction(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward)
|
||||
data object CopyText : TimelineItemAction(CommonStrings.action_copy_text, CompoundDrawables.ic_compound_copy)
|
||||
data object CopyCaption : TimelineItemAction(CommonStrings.action_copy_caption, CompoundDrawables.ic_compound_copy)
|
||||
data object CopyLink : TimelineItemAction(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link)
|
||||
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 EditCaption : TimelineItemAction(CommonStrings.action_edit_caption, CompoundDrawables.ic_compound_edit)
|
||||
data object AddCaption : TimelineItemAction(CommonStrings.action_add_caption, CompoundDrawables.ic_compound_edit)
|
||||
data object RemoveCaption : TimelineItemAction(CommonStrings.action_remove_caption, CompoundDrawables.ic_compound_delete, destructive = true)
|
||||
data object ViewSource : TimelineItemAction(CommonStrings.action_view_source, CommonDrawables.ic_developer_options)
|
||||
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)
|
||||
data object Pin : TimelineItemAction(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin)
|
||||
data object Unpin : TimelineItemAction(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin)
|
||||
ViewInTimeline(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on),
|
||||
Forward(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward),
|
||||
CopyText(CommonStrings.action_copy_text, CompoundDrawables.ic_compound_copy),
|
||||
CopyCaption(CommonStrings.action_copy_caption, CompoundDrawables.ic_compound_copy),
|
||||
CopyLink(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link),
|
||||
Redact(CommonStrings.action_remove, CompoundDrawables.ic_compound_delete, destructive = true),
|
||||
Reply(CommonStrings.action_reply, CompoundDrawables.ic_compound_reply),
|
||||
ReplyInThread(CommonStrings.action_reply_in_thread, CompoundDrawables.ic_compound_reply),
|
||||
Edit(CommonStrings.action_edit, CompoundDrawables.ic_compound_edit),
|
||||
EditPoll(CommonStrings.action_edit_poll, CompoundDrawables.ic_compound_edit),
|
||||
EditCaption(CommonStrings.action_edit_caption, CompoundDrawables.ic_compound_edit),
|
||||
AddCaption(CommonStrings.action_add_caption, CompoundDrawables.ic_compound_edit),
|
||||
RemoveCaption(CommonStrings.action_remove_caption, CompoundDrawables.ic_compound_close, destructive = true),
|
||||
ViewSource(CommonStrings.action_view_source, CompoundDrawables.ic_compound_code),
|
||||
ReportContent(CommonStrings.action_report_content, CompoundDrawables.ic_compound_chat_problem, destructive = true),
|
||||
EndPoll(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end),
|
||||
Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin),
|
||||
Unpin(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,21 +7,25 @@
|
|||
|
||||
package io.element.android.features.messages.impl.actionlist.model
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
|
||||
class TimelineItemActionComparator : Comparator<TimelineItemAction> {
|
||||
// See order in https://www.figma.com/design/ux3tYoZV9WghC7hHT9Fhk0/Compound-iOS-Components?node-id=2946-2392
|
||||
private val orderedList = listOf(
|
||||
@VisibleForTesting
|
||||
val orderedList = listOf(
|
||||
TimelineItemAction.EndPoll,
|
||||
TimelineItemAction.ViewInTimeline,
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.ReplyInThread,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.Unpin,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.EditPoll,
|
||||
TimelineItemAction.AddCaption,
|
||||
TimelineItemAction.EditCaption,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.Unpin,
|
||||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.CopyCaption,
|
||||
TimelineItemAction.RemoveCaption,
|
||||
TimelineItemAction.ViewSource,
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ class PinnedMessagesListTimelineActionPostProcessor : TimelineItemActionPostProc
|
|||
override fun process(actions: List<TimelineItemAction>): List<TimelineItemAction> {
|
||||
return buildList {
|
||||
add(TimelineItemAction.ViewInTimeline)
|
||||
actions.firstOrNull { it is TimelineItemAction.Unpin }?.let(::add)
|
||||
actions.firstOrNull { it is TimelineItemAction.Forward }?.let(::add)
|
||||
actions.firstOrNull { it is TimelineItemAction.ViewSource }?.let(::add)
|
||||
actions.firstOrNull { it == TimelineItemAction.Unpin }?.let(::add)
|
||||
actions.firstOrNull { it == TimelineItemAction.Forward }?.let(::add)
|
||||
actions.firstOrNull { it == TimelineItemAction.ViewSource }?.let(::add)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,9 +40,12 @@ fun TimelineItemEncryptedView(
|
|||
UtdCause.UnknownDevice -> {
|
||||
CommonStrings.common_unable_to_decrypt_insecure_device to CompoundDrawables.ic_compound_block
|
||||
}
|
||||
UtdCause.HistoricalMessage -> {
|
||||
UtdCause.HistoricalMessageAndBackupIsDisabled -> {
|
||||
CommonStrings.timeline_decryption_failure_historical_event_no_key_backup to CompoundDrawables.ic_compound_block
|
||||
}
|
||||
UtdCause.HistoricalMessageAndDeviceIsUnverified -> {
|
||||
CommonStrings.timeline_decryption_failure_historical_event_unverified_device to CompoundDrawables.ic_compound_block
|
||||
}
|
||||
UtdCause.WithheldUnverifiedOrInsecureDevice -> {
|
||||
CommonStrings.timeline_decryption_failure_withheld_unverified to CompoundDrawables.ic_compound_block
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ 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.voicemessages.timeline.VoiceMessageState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||
|
||||
@Composable
|
||||
fun TimelineItemEventContentView(
|
||||
|
|
|
|||
|
|
@ -40,9 +40,6 @@ 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
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageEvents
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageStateProvider
|
||||
import io.element.android.libraries.androidutils.accessibility.isScreenReaderEnabled
|
||||
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
|
|
@ -52,6 +49,9 @@ 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.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@
|
|||
package io.element.android.features.messages.impl.timeline.di
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.aVoiceMessageState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
|
||||
|
||||
/**
|
||||
* A fake [TimelineItemPresenterFactories] for screenshot tests.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemGrou
|
|||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
|
||||
import io.element.android.libraries.core.bool.orTrue
|
||||
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
|
|
@ -32,14 +33,13 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getDisambigua
|
|||
import io.element.android.libraries.matrix.ui.messages.reply.map
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
class TimelineItemEventFactory @AssistedInject constructor(
|
||||
@Assisted private val config: TimelineItemsFactoryConfig,
|
||||
private val contentFactory: TimelineItemContentFactory,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
|
||||
private val dateFormatter: DateFormatter,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
) {
|
||||
@AssistedFactory
|
||||
|
|
@ -57,9 +57,10 @@ class TimelineItemEventFactory @AssistedInject constructor(
|
|||
val groupPosition =
|
||||
computeGroupPosition(currentTimelineItem, timelineItems, index)
|
||||
val senderProfile = currentTimelineItem.event.senderProfile
|
||||
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
|
||||
val sentTime = timeFormatter.format(Date(currentTimelineItem.event.timestamp))
|
||||
|
||||
val sentTime = dateFormatter.format(
|
||||
timestamp = currentTimelineItem.event.timestamp,
|
||||
mode = DateFormatterMode.TimeOnly,
|
||||
)
|
||||
val senderAvatarData = AvatarData(
|
||||
id = currentSender.value,
|
||||
name = senderProfile.getDisambiguatedDisplayName(currentSender),
|
||||
|
|
@ -78,6 +79,7 @@ class TimelineItemEventFactory @AssistedInject constructor(
|
|||
isMine = currentTimelineItem.event.isOwn,
|
||||
isEditable = currentTimelineItem.event.isEditable,
|
||||
canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo,
|
||||
sentTimeMillis = currentTimelineItem.event.timestamp,
|
||||
sentTime = sentTime,
|
||||
groupPosition = groupPosition,
|
||||
reactionsState = currentTimelineItem.computeReactionsState(),
|
||||
|
|
@ -106,7 +108,6 @@ class TimelineItemEventFactory @AssistedInject constructor(
|
|||
if (!config.computeReactions) {
|
||||
return TimelineItemReactions(reactions = persistentListOf())
|
||||
}
|
||||
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
|
||||
var aggregatedReactions = this.event.reactions.map { reaction ->
|
||||
// Sort reactions within an aggregation by timestamp descending.
|
||||
// This puts the most recent at the top, useful in cases like the
|
||||
|
|
@ -121,7 +122,10 @@ class TimelineItemEventFactory @AssistedInject constructor(
|
|||
AggregatedReactionSender(
|
||||
senderId = it.senderId,
|
||||
timestamp = date,
|
||||
sentTime = timeFormatter.format(date),
|
||||
sentTime = dateFormatter.format(
|
||||
it.timestamp,
|
||||
DateFormatterMode.TimeOrDate,
|
||||
),
|
||||
)
|
||||
}
|
||||
.toImmutableList()
|
||||
|
|
@ -157,7 +161,10 @@ class TimelineItemEventFactory @AssistedInject constructor(
|
|||
url = roomMember?.avatarUrl,
|
||||
size = AvatarSize.TimelineReadReceipt,
|
||||
),
|
||||
formattedDate = lastMessageTimestampFormatter.format(receipt.timestamp)
|
||||
formattedDate = dateFormatter.format(
|
||||
receipt.timestamp,
|
||||
mode = DateFormatterMode.TimeOrDate,
|
||||
)
|
||||
)
|
||||
}
|
||||
.toImmutableList()
|
||||
|
|
|
|||
|
|
@ -9,13 +9,20 @@ package io.element.android.features.messages.impl.timeline.factories.virtual
|
|||
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
|
||||
import io.element.android.libraries.dateformatter.api.DaySeparatorFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
import javax.inject.Inject
|
||||
|
||||
class TimelineItemDaySeparatorFactory @Inject constructor(private val daySeparatorFormatter: DaySeparatorFormatter) {
|
||||
class TimelineItemDaySeparatorFactory @Inject constructor(
|
||||
private val dateFormatter: DateFormatter,
|
||||
) {
|
||||
fun create(virtualItem: VirtualTimelineItem.DayDivider): TimelineItemVirtualModel {
|
||||
val formattedDate = daySeparatorFormatter.format(virtualItem.timestamp)
|
||||
val formattedDate = dateFormatter.format(
|
||||
timestamp = virtualItem.timestamp,
|
||||
mode = DateFormatterMode.Day,
|
||||
useRelative = true,
|
||||
)
|
||||
return TimelineItemDaySeparatorModel(
|
||||
formattedDate = formattedDate
|
||||
)
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ sealed interface TimelineItem {
|
|||
val senderProfile: ProfileTimelineDetails,
|
||||
val senderAvatar: AvatarData,
|
||||
val content: TimelineItemEventContent,
|
||||
val sentTimeMillis: Long = 0L,
|
||||
val sentTime: String = "",
|
||||
val isMine: Boolean = false,
|
||||
val isEditable: Boolean,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,13 @@ open class TimelineItemEncryptedContentProvider : PreviewParameterProvider<Timel
|
|||
aTimelineItemEncryptedContent(
|
||||
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
|
||||
sessionId = "sessionId",
|
||||
utdCause = UtdCause.HistoricalMessage,
|
||||
utdCause = UtdCause.HistoricalMessageAndBackupIsDisabled,
|
||||
)
|
||||
),
|
||||
aTimelineItemEncryptedContent(
|
||||
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
|
||||
sessionId = "sessionId",
|
||||
utdCause = UtdCause.HistoricalMessageAndDeviceIsUnverified,
|
||||
)
|
||||
),
|
||||
aTimelineItemEncryptedContent(
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages
|
||||
|
||||
internal sealed class VoiceMessageException : Exception() {
|
||||
data class FileException(
|
||||
override val message: String?,
|
||||
override val cause: Throwable? = null
|
||||
) : VoiceMessageException()
|
||||
data class PermissionMissing(
|
||||
override val message: String?,
|
||||
override val cause: Throwable?
|
||||
) : VoiceMessageException()
|
||||
data class PlayMessageError(
|
||||
override val message: String?,
|
||||
override val cause: Throwable?
|
||||
) : VoiceMessageException()
|
||||
}
|
||||
|
|
@ -21,7 +21,6 @@ import androidx.core.net.toUri
|
|||
import androidx.lifecycle.Lifecycle
|
||||
import im.vector.app.features.analytics.plan.Composer
|
||||
import io.element.android.features.messages.api.MessageComposerContext
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||
|
|
@ -29,6 +28,7 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
|
|||
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
|
||||
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
|
||||
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages.timeline
|
||||
|
||||
sealed interface VoiceMessageEvents {
|
||||
data object PlayPause : VoiceMessageEvents
|
||||
data class Seek(val percentage: Float) : VoiceMessageEvents
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages.timeline
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.libraries.di.CacheDirectory
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.mxc.MxcTools
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Fetches the media file for a voice message.
|
||||
*
|
||||
* Media is downloaded from the rust sdk and stored in the application's cache directory.
|
||||
* Media files are indexed by their Matrix Content (mxc://) URI and considered immutable.
|
||||
* Whenever a given mxc is found in the cache, it is returned immediately.
|
||||
*/
|
||||
interface VoiceMessageMediaRepo {
|
||||
/**
|
||||
* Factory for [VoiceMessageMediaRepo].
|
||||
*/
|
||||
fun interface Factory {
|
||||
/**
|
||||
* Creates a [VoiceMessageMediaRepo].
|
||||
*
|
||||
* @param mediaSource the media source of the voice message.
|
||||
* @param mimeType the mime type of the voice message.
|
||||
* @param filename the filename of the voice message.
|
||||
*/
|
||||
fun create(
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
filename: String?,
|
||||
): VoiceMessageMediaRepo
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the voice message media file.
|
||||
*
|
||||
* In case of a cache hit the file is returned immediately.
|
||||
* In case of a cache miss the file is downloaded and then returned.
|
||||
*
|
||||
* @return A [Result] holding either the media [File] from the cache directory or an [Exception].
|
||||
*/
|
||||
suspend fun getMediaFile(): Result<File>
|
||||
}
|
||||
|
||||
class DefaultVoiceMessageMediaRepo @AssistedInject constructor(
|
||||
@CacheDirectory private val cacheDir: File,
|
||||
mxcTools: MxcTools,
|
||||
private val matrixMediaLoader: MatrixMediaLoader,
|
||||
@Assisted private val mediaSource: MediaSource,
|
||||
@Assisted("mimeType") private val mimeType: String?,
|
||||
@Assisted("filename") private val filename: String?,
|
||||
) : VoiceMessageMediaRepo {
|
||||
@ContributesBinding(RoomScope::class)
|
||||
@AssistedFactory
|
||||
fun interface Factory : VoiceMessageMediaRepo.Factory {
|
||||
override fun create(
|
||||
mediaSource: MediaSource,
|
||||
@Assisted("mimeType") mimeType: String?,
|
||||
@Assisted("filename") filename: String?,
|
||||
): DefaultVoiceMessageMediaRepo
|
||||
}
|
||||
|
||||
override suspend fun getMediaFile(): Result<File> = when {
|
||||
cachedFile == null -> Result.failure(IllegalStateException("Invalid mxcUri."))
|
||||
cachedFile.exists() -> Result.success(cachedFile)
|
||||
else -> matrixMediaLoader.downloadMediaFile(
|
||||
source = mediaSource,
|
||||
mimeType = mimeType,
|
||||
filename = filename,
|
||||
).mapCatching {
|
||||
it.use { mediaFile ->
|
||||
val dest = cachedFile.apply { parentFile?.mkdirs() }
|
||||
if (mediaFile.persist(dest.path)) {
|
||||
dest
|
||||
} else {
|
||||
error("Failed to move file to cache.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val cachedFile: File? = mxcTools.mxcUri2FilePath(mediaSource.url)?.let {
|
||||
File("${cacheDir.path}/$CACHE_VOICE_SUBDIR/$it")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subdirectory of the application's cache directory where voice messages are stored.
|
||||
*/
|
||||
private const val CACHE_VOICE_SUBDIR = "temp/voice"
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages.timeline
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* A media player specialized in playing a single voice message.
|
||||
*/
|
||||
interface VoiceMessagePlayer {
|
||||
fun interface Factory {
|
||||
/**
|
||||
* Creates a [VoiceMessagePlayer].
|
||||
*
|
||||
* NB: Different voice messages can use the same content uri (e.g. in case of
|
||||
* a forward of a voice message),
|
||||
* therefore the mxc:// uri in [mediaSource] is not enough to uniquely identify
|
||||
* a voice message. This is why we must provide the eventId as well.
|
||||
*
|
||||
* @param eventId The eventId of the voice message event.
|
||||
* @param mediaSource The media source of the voice message.
|
||||
* @param mimeType The mime type of the voice message.
|
||||
* @param filename The filename of the voice message.
|
||||
*/
|
||||
fun create(
|
||||
eventId: EventId?,
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
filename: String?,
|
||||
): VoiceMessagePlayer
|
||||
}
|
||||
|
||||
/**
|
||||
* The current state of this player.
|
||||
*/
|
||||
val state: Flow<State>
|
||||
|
||||
/**
|
||||
* Acquires control of the underlying [MediaPlayer] and prepares it
|
||||
* to play the media file.
|
||||
*
|
||||
* Will suspend whilst the media file is being downloaded and/or
|
||||
* the underlying [MediaPlayer] is loading the media file.
|
||||
*/
|
||||
suspend fun prepare(): Result<Unit>
|
||||
|
||||
/**
|
||||
* Play the media.
|
||||
*/
|
||||
fun play()
|
||||
|
||||
/**
|
||||
* Pause playback.
|
||||
*/
|
||||
fun pause()
|
||||
|
||||
/**
|
||||
* Seek to a specific position.
|
||||
*
|
||||
* @param positionMs The position in milliseconds.
|
||||
*/
|
||||
fun seekTo(positionMs: Long)
|
||||
|
||||
data class State(
|
||||
/**
|
||||
* Whether the player is ready to play.
|
||||
*/
|
||||
val isReady: Boolean,
|
||||
/**
|
||||
* Whether this player is currently playing.
|
||||
*/
|
||||
val isPlaying: Boolean,
|
||||
/**
|
||||
* Whether the player has reached the end of the media.
|
||||
*/
|
||||
val isEnded: Boolean,
|
||||
/**
|
||||
* The elapsed time of this player in milliseconds.
|
||||
*/
|
||||
val currentPosition: Long,
|
||||
/**
|
||||
* The duration of the current content, if available.
|
||||
*/
|
||||
val duration: Long?,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An implementation of [VoiceMessagePlayer] which is backed by a
|
||||
* [VoiceMessageMediaRepo] to fetch and cache the media file and
|
||||
* which uses a global [MediaPlayer] instance to play the media.
|
||||
*/
|
||||
class DefaultVoiceMessagePlayer(
|
||||
private val mediaPlayer: MediaPlayer,
|
||||
voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory,
|
||||
private val eventId: EventId?,
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
filename: String?,
|
||||
) : VoiceMessagePlayer {
|
||||
@ContributesBinding(RoomScope::class) // Scoped types can't use @AssistedInject.
|
||||
class Factory @Inject constructor(
|
||||
private val mediaPlayer: MediaPlayer,
|
||||
private val voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory,
|
||||
) : VoiceMessagePlayer.Factory {
|
||||
override fun create(
|
||||
eventId: EventId?,
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
filename: String?,
|
||||
): DefaultVoiceMessagePlayer = DefaultVoiceMessagePlayer(
|
||||
mediaPlayer = mediaPlayer,
|
||||
voiceMessageMediaRepoFactory = voiceMessageMediaRepoFactory,
|
||||
eventId = eventId,
|
||||
mediaSource = mediaSource,
|
||||
mimeType = mimeType,
|
||||
filename = filename,
|
||||
)
|
||||
}
|
||||
|
||||
private val repo = voiceMessageMediaRepoFactory.create(
|
||||
mediaSource = mediaSource,
|
||||
mimeType = mimeType,
|
||||
filename = filename,
|
||||
)
|
||||
|
||||
private var internalState = MutableStateFlow(
|
||||
VoiceMessagePlayer.State(
|
||||
isReady = false,
|
||||
isPlaying = false,
|
||||
isEnded = false,
|
||||
currentPosition = 0L,
|
||||
duration = null
|
||||
)
|
||||
)
|
||||
|
||||
override val state: Flow<VoiceMessagePlayer.State> = combine(mediaPlayer.state, internalState) { mediaPlayerState, internalState ->
|
||||
if (mediaPlayerState.isMyTrack) {
|
||||
this.internalState.update {
|
||||
it.copy(
|
||||
isReady = mediaPlayerState.isReady,
|
||||
isPlaying = mediaPlayerState.isPlaying,
|
||||
isEnded = mediaPlayerState.isEnded,
|
||||
currentPosition = mediaPlayerState.currentPosition,
|
||||
duration = mediaPlayerState.duration,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
this.internalState.update {
|
||||
it.copy(
|
||||
isReady = false,
|
||||
isPlaying = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
VoiceMessagePlayer.State(
|
||||
isReady = internalState.isReady,
|
||||
isPlaying = internalState.isPlaying,
|
||||
isEnded = internalState.isEnded,
|
||||
currentPosition = internalState.currentPosition,
|
||||
duration = internalState.duration,
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
override suspend fun prepare(): Result<Unit> = if (eventId != null) {
|
||||
repo.getMediaFile().mapCatching<Unit, File> { mediaFile ->
|
||||
val state = internalState.value
|
||||
mediaPlayer.setMedia(
|
||||
uri = mediaFile.path,
|
||||
mediaId = eventId.value,
|
||||
// Files in the voice cache have no extension so we need to set the mime type manually.
|
||||
mimeType = MimeTypes.Ogg,
|
||||
startPositionMs = if (state.isEnded) 0L else state.currentPosition,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Result.failure(IllegalStateException("Cannot acquireControl on a voice message with no eventId"))
|
||||
}
|
||||
|
||||
override fun play() {
|
||||
if (inControl()) {
|
||||
mediaPlayer.play()
|
||||
}
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
if (inControl()) {
|
||||
mediaPlayer.pause()
|
||||
}
|
||||
}
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
if (inControl()) {
|
||||
mediaPlayer.seekTo(positionMs)
|
||||
} else {
|
||||
internalState.update {
|
||||
it.copy(currentPosition = positionMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val MediaPlayer.State.isMyTrack: Boolean
|
||||
get() = if (eventId == null) false else this.mediaId == eventId.value
|
||||
|
||||
private fun inControl(): Boolean = mediaPlayer.state.value.let {
|
||||
it.isMyTrack && (it.isReady || it.isEnded)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,11 +8,6 @@
|
|||
package io.element.android.features.messages.impl.voicemessages.timeline
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
|
|
@ -23,17 +18,10 @@ import dagger.multibindings.IntoMap
|
|||
import io.element.android.features.messages.impl.timeline.di.TimelineItemEventContentKey
|
||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactory
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runUpdatingState
|
||||
import io.element.android.libraries.core.extensions.flatMap
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.ui.utils.time.formatShort
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessagePresenterFactory
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||
|
||||
@Module
|
||||
@ContributesTo(RoomScope::class)
|
||||
|
|
@ -45,9 +33,7 @@ interface VoiceMessagePresenterModule {
|
|||
}
|
||||
|
||||
class VoiceMessagePresenter @AssistedInject constructor(
|
||||
voiceMessagePlayerFactory: VoiceMessagePlayer.Factory,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val scope: CoroutineScope,
|
||||
voiceMessagePresenterFactory: VoiceMessagePresenterFactory,
|
||||
@Assisted private val content: TimelineItemVoiceContent,
|
||||
) : Presenter<VoiceMessageState> {
|
||||
@AssistedFactory
|
||||
|
|
@ -55,97 +41,16 @@ class VoiceMessagePresenter @AssistedInject constructor(
|
|||
override fun create(content: TimelineItemVoiceContent): VoiceMessagePresenter
|
||||
}
|
||||
|
||||
private val player = voiceMessagePlayerFactory.create(
|
||||
private val presenter = voiceMessagePresenterFactory.createVoiceMessagePresenter(
|
||||
eventId = content.eventId,
|
||||
mediaSource = content.mediaSource,
|
||||
mimeType = content.mimeType,
|
||||
filename = content.filename,
|
||||
duration = content.duration,
|
||||
)
|
||||
|
||||
private val play = mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized)
|
||||
|
||||
@Composable
|
||||
override fun present(): VoiceMessageState {
|
||||
val playerState by player.state.collectAsState(
|
||||
VoiceMessagePlayer.State(
|
||||
isReady = false,
|
||||
isPlaying = false,
|
||||
isEnded = false,
|
||||
currentPosition = 0L,
|
||||
duration = null
|
||||
)
|
||||
)
|
||||
|
||||
val button by remember {
|
||||
derivedStateOf {
|
||||
when {
|
||||
content.eventId == null -> VoiceMessageState.Button.Disabled
|
||||
playerState.isPlaying -> VoiceMessageState.Button.Pause
|
||||
play.value is AsyncData.Loading -> VoiceMessageState.Button.Downloading
|
||||
play.value is AsyncData.Failure -> VoiceMessageState.Button.Retry
|
||||
else -> VoiceMessageState.Button.Play
|
||||
}
|
||||
}
|
||||
}
|
||||
val duration by remember {
|
||||
derivedStateOf { playerState.duration ?: content.duration.inWholeMilliseconds }
|
||||
}
|
||||
val progress by remember {
|
||||
derivedStateOf {
|
||||
playerState.currentPosition / duration.toFloat()
|
||||
}
|
||||
}
|
||||
val time by remember {
|
||||
derivedStateOf {
|
||||
when {
|
||||
playerState.isReady && !playerState.isEnded -> playerState.currentPosition
|
||||
playerState.currentPosition > 0 -> playerState.currentPosition
|
||||
else -> duration
|
||||
}.milliseconds.formatShort()
|
||||
}
|
||||
}
|
||||
val showCursor by remember {
|
||||
derivedStateOf {
|
||||
!play.value.isUninitialized() && !playerState.isEnded
|
||||
}
|
||||
}
|
||||
|
||||
fun eventSink(event: VoiceMessageEvents) {
|
||||
when (event) {
|
||||
is VoiceMessageEvents.PlayPause -> {
|
||||
if (playerState.isPlaying) {
|
||||
player.pause()
|
||||
} else if (playerState.isReady) {
|
||||
player.play()
|
||||
} else {
|
||||
scope.launch {
|
||||
play.runUpdatingState(
|
||||
errorTransform = {
|
||||
analyticsService.trackError(
|
||||
VoiceMessageException.PlayMessageError("Error while trying to play voice message", it)
|
||||
)
|
||||
it
|
||||
},
|
||||
) {
|
||||
player.prepare().flatMap {
|
||||
runCatching { player.play() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is VoiceMessageEvents.Seek -> {
|
||||
player.seekTo((event.percentage * duration).toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return VoiceMessageState(
|
||||
button = button,
|
||||
progress = progress,
|
||||
time = time,
|
||||
showCursor = showCursor,
|
||||
eventSink = { eventSink(it) },
|
||||
)
|
||||
return presenter.present()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages.timeline
|
||||
|
||||
data class VoiceMessageState(
|
||||
val button: Button,
|
||||
val progress: Float,
|
||||
val time: String,
|
||||
val showCursor: Boolean,
|
||||
val eventSink: (event: VoiceMessageEvents) -> Unit,
|
||||
) {
|
||||
enum class Button {
|
||||
Play,
|
||||
Pause,
|
||||
Downloading,
|
||||
Retry,
|
||||
Disabled,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages.timeline
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
|
||||
open class VoiceMessageStateProvider : PreviewParameterProvider<VoiceMessageState> {
|
||||
override val values: Sequence<VoiceMessageState>
|
||||
get() = sequenceOf(
|
||||
aVoiceMessageState(
|
||||
VoiceMessageState.Button.Downloading,
|
||||
progress = 0f,
|
||||
time = "0:00",
|
||||
),
|
||||
aVoiceMessageState(
|
||||
VoiceMessageState.Button.Retry,
|
||||
progress = 0.5f,
|
||||
time = "0:01",
|
||||
),
|
||||
aVoiceMessageState(
|
||||
VoiceMessageState.Button.Play,
|
||||
progress = 1f,
|
||||
time = "1:00",
|
||||
showCursor = true,
|
||||
),
|
||||
aVoiceMessageState(
|
||||
VoiceMessageState.Button.Pause,
|
||||
progress = 0.2f,
|
||||
time = "10:00",
|
||||
showCursor = true,
|
||||
),
|
||||
aVoiceMessageState(
|
||||
VoiceMessageState.Button.Disabled,
|
||||
progress = 0.2f,
|
||||
time = "30:00",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aVoiceMessageState(
|
||||
button: VoiceMessageState.Button = VoiceMessageState.Button.Play,
|
||||
progress: Float = 0f,
|
||||
time: String = "1:00",
|
||||
showCursor: Boolean = false,
|
||||
) = VoiceMessageState(
|
||||
button = button,
|
||||
progress = progress,
|
||||
time = time,
|
||||
showCursor = showCursor,
|
||||
eventSink = {},
|
||||
)
|
||||
|
|
@ -467,7 +467,7 @@ class MessagesPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent(content = aTimelineItemPollContent())))
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EditPoll, aMessageEvent(content = aTimelineItemPollContent())))
|
||||
awaitItem()
|
||||
onEditPollClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -327,6 +327,7 @@ class MessagesViewTest {
|
|||
actionListState = anActionListState(
|
||||
target = ActionListState.Target.Success(
|
||||
event = timelineItem,
|
||||
sentTimeFull = "",
|
||||
displayEmojiReactions = true,
|
||||
actions = persistentListOf(TimelineItemAction.Edit),
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
|
|
@ -399,6 +400,7 @@ class MessagesViewTest {
|
|||
actionListState = anActionListState(
|
||||
target = ActionListState.Target.Success(
|
||||
event = timelineItem,
|
||||
sentTimeFull = "",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(TimelineItemAction.Edit),
|
||||
|
|
@ -427,6 +429,7 @@ class MessagesViewTest {
|
|||
actionListState = anActionListState(
|
||||
target = ActionListState.Target.Success(
|
||||
event = timelineItem,
|
||||
sentTimeFull = "",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = aChangedIdentitySendFailure(),
|
||||
actions = persistentListOf(),
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
|
|||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
|
||||
import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
|
||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
|
|
@ -86,6 +87,7 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = false,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
|
|
@ -128,6 +130,7 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = false,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
|
|
@ -170,13 +173,14 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
|
|
@ -215,13 +219,14 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.ReplyInThread,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
|
|
@ -263,12 +268,13 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
|
|
@ -308,13 +314,14 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
|
|
@ -355,13 +362,14 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = false,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
|
|
@ -403,14 +411,15 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.Redact,
|
||||
|
|
@ -448,14 +457,15 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.ReplyInThread,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.Redact,
|
||||
|
|
@ -496,14 +506,15 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.ViewSource,
|
||||
)
|
||||
|
|
@ -542,14 +553,15 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.AddCaption,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
|
|
@ -592,6 +604,7 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
|
|
@ -599,8 +612,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.Forward,
|
||||
// Not here
|
||||
// TimelineItemAction.AddCaption,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
|
|
@ -641,14 +654,15 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.EditCaption,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyCaption,
|
||||
TimelineItemAction.RemoveCaption,
|
||||
TimelineItemAction.ViewSource,
|
||||
|
|
@ -691,13 +705,14 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyCaption,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
|
|
@ -738,6 +753,7 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = stateEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = false,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
|
|
@ -808,14 +824,15 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
|
|
@ -855,13 +872,14 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.Redact,
|
||||
|
|
@ -909,14 +927,15 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Unpin,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Unpin,
|
||||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.Redact,
|
||||
|
|
@ -1006,6 +1025,7 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
|
|
@ -1046,14 +1066,15 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.EndPoll,
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.EditPoll,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
)
|
||||
|
|
@ -1089,13 +1110,14 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.EndPoll,
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
)
|
||||
|
|
@ -1131,12 +1153,13 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
)
|
||||
|
|
@ -1174,13 +1197,14 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
)
|
||||
|
|
@ -1214,6 +1238,7 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
event = messageEvent,
|
||||
sentTimeFull = "0 Full true",
|
||||
displayEmojiReactions = false,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
|
|
@ -1268,6 +1293,7 @@ private fun createActionListPresenter(
|
|||
initialState = mapOf(
|
||||
FeatureFlags.MediaCaptionCreation.key to allowCaption,
|
||||
),
|
||||
)
|
||||
),
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.actionlist.model
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
class TimelineItemActionComparatorTest {
|
||||
@Test
|
||||
fun `check that the list in the comparator only contain each item once`() {
|
||||
val sut = TimelineItemActionComparator()
|
||||
sut.orderedList.forEach {
|
||||
require(sut.orderedList.count { item -> item == it } == 1, { "Duplicate ${it::class.java}.$it" })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `check that the list in the comparator contains all the items`() {
|
||||
val sut = TimelineItemActionComparator()
|
||||
TimelineItemAction.entries.forEach {
|
||||
require(it in sut.orderedList, { "Missing ${it::class.simpleName}.$it in orderedList" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,8 +28,7 @@ import io.element.android.features.messages.impl.utils.FakeTextPillificationHelp
|
|||
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
|
||||
import io.element.android.features.poll.test.pollcontent.FakePollContentStateFactory
|
||||
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
||||
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
|
||||
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
|
||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||
|
|
@ -80,7 +79,7 @@ internal fun TestScope.aTimelineItemsFactory(
|
|||
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
|
||||
),
|
||||
matrixClient = matrixClient,
|
||||
lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(),
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
config = config
|
||||
)
|
||||
|
|
@ -88,7 +87,7 @@ internal fun TestScope.aTimelineItemsFactory(
|
|||
},
|
||||
virtualItemFactory = TimelineItemVirtualFactory(
|
||||
daySeparatorFactory = TimelineItemDaySeparatorFactory(
|
||||
FakeDaySeparatorFormatter()
|
||||
FakeDateFormatter()
|
||||
),
|
||||
),
|
||||
timelineItemGrouper = TimelineItemGrouper(),
|
||||
|
|
|
|||
|
|
@ -27,24 +27,7 @@ class PinnedMessagesListTimelineActionPostProcessorTest {
|
|||
fun `ensure that some actions are kept and some other are filtered out`() {
|
||||
val sut = PinnedMessagesListTimelineActionPostProcessor()
|
||||
val result = sut.process(
|
||||
listOf(
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.CopyCaption,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Redact,
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.ReplyInThread,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.EditCaption,
|
||||
TimelineItemAction.AddCaption,
|
||||
TimelineItemAction.RemoveCaption,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
TimelineItemAction.EndPoll,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.Unpin,
|
||||
)
|
||||
TimelineItemAction.entries.toList()
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
listOf(
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import app.cash.turbine.test
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.Composer
|
||||
import io.element.android.features.messages.impl.messagecomposer.aReplyMode
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
|
||||
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
|
|
@ -36,6 +35,7 @@ import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
|||
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
|
||||
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
|
|
|
|||
|
|
@ -1,151 +0,0 @@
|
|||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages.timeline
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.mxc.MxcTools
|
||||
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
class DefaultVoiceMessageMediaRepoTest {
|
||||
@get:Rule
|
||||
val temporaryFolder = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun `cache miss - downloads and returns cached file successfully`() = runTest {
|
||||
val matrixMediaLoader = FakeMatrixMediaLoader().apply {
|
||||
path = temporaryFolder.createRustMediaFile().path
|
||||
}
|
||||
val repo = createDefaultVoiceMessageMediaRepo(
|
||||
temporaryFolder = temporaryFolder,
|
||||
matrixMediaLoader = matrixMediaLoader,
|
||||
)
|
||||
|
||||
repo.getMediaFile().let { result ->
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
result.getOrThrow().let { file ->
|
||||
assertThat(file.path).isEqualTo(temporaryFolder.cachedFilePath)
|
||||
assertThat(file.exists()).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cache miss - download fails`() = runTest {
|
||||
val matrixMediaLoader = FakeMatrixMediaLoader().apply {
|
||||
shouldFail = true
|
||||
}
|
||||
val repo = createDefaultVoiceMessageMediaRepo(
|
||||
temporaryFolder = temporaryFolder,
|
||||
matrixMediaLoader = matrixMediaLoader,
|
||||
)
|
||||
|
||||
repo.getMediaFile().let { result ->
|
||||
assertThat(result.isFailure).isTrue()
|
||||
result.exceptionOrNull()!!.let { exception ->
|
||||
assertThat(exception).isInstanceOf(RuntimeException::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cache miss - download succeeds but file move fails`() = runTest {
|
||||
val matrixMediaLoader = FakeMatrixMediaLoader().apply {
|
||||
path = temporaryFolder.createRustMediaFile().path
|
||||
}
|
||||
File(temporaryFolder.cachedFilePath).apply {
|
||||
parentFile?.mkdirs()
|
||||
// Deny access to parent folder so move to cache will fail.
|
||||
parentFile?.setReadable(false)
|
||||
parentFile?.setWritable(false)
|
||||
parentFile?.setExecutable(false)
|
||||
}
|
||||
val repo = createDefaultVoiceMessageMediaRepo(
|
||||
temporaryFolder = temporaryFolder,
|
||||
matrixMediaLoader = matrixMediaLoader,
|
||||
)
|
||||
|
||||
repo.getMediaFile().let { result ->
|
||||
assertThat(result.isFailure).isTrue()
|
||||
result.exceptionOrNull()?.let { exception ->
|
||||
assertThat(exception).apply {
|
||||
isInstanceOf(IllegalStateException::class.java)
|
||||
hasMessageThat().isEqualTo("Failed to move file to cache.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cache hit - returns cached file successfully`() = runTest {
|
||||
temporaryFolder.createCachedFile()
|
||||
val matrixMediaLoader = FakeMatrixMediaLoader().apply {
|
||||
shouldFail = true // so that if we hit the media loader it will crash
|
||||
}
|
||||
val repo = createDefaultVoiceMessageMediaRepo(
|
||||
temporaryFolder = temporaryFolder,
|
||||
matrixMediaLoader = matrixMediaLoader,
|
||||
)
|
||||
|
||||
repo.getMediaFile().let { result ->
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
result.getOrThrow().let { file ->
|
||||
assertThat(file.path).isEqualTo(temporaryFolder.cachedFilePath)
|
||||
assertThat(file.exists()).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid mxc uri returns a failure`() = runTest {
|
||||
val repo = createDefaultVoiceMessageMediaRepo(
|
||||
temporaryFolder = temporaryFolder,
|
||||
mxcUri = INVALID_MXC_URI,
|
||||
)
|
||||
repo.getMediaFile().let { result ->
|
||||
assertThat(result.isFailure).isTrue()
|
||||
result.exceptionOrNull()!!.let { exception ->
|
||||
assertThat(exception).isInstanceOf(RuntimeException::class.java)
|
||||
assertThat(exception).hasMessageThat().isEqualTo("Invalid mxcUri.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDefaultVoiceMessageMediaRepo(
|
||||
temporaryFolder: TemporaryFolder,
|
||||
matrixMediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(),
|
||||
mxcUri: String = MXC_URI,
|
||||
) = DefaultVoiceMessageMediaRepo(
|
||||
cacheDir = temporaryFolder.root,
|
||||
mxcTools = MxcTools(),
|
||||
matrixMediaLoader = matrixMediaLoader,
|
||||
mediaSource = MediaSource(
|
||||
url = mxcUri,
|
||||
json = null
|
||||
),
|
||||
mimeType = MimeTypes.Ogg,
|
||||
filename = "someBody.ogg"
|
||||
)
|
||||
|
||||
private const val MXC_URI = "mxc://matrix.org/1234567890abcdefg"
|
||||
private const val INVALID_MXC_URI = "notAnMxcUri"
|
||||
private val TemporaryFolder.cachedFilePath get() = "${this.root.path}/temp/voice/matrix.org/1234567890abcdefg"
|
||||
private fun TemporaryFolder.createCachedFile() = File(cachedFilePath).apply {
|
||||
parentFile?.mkdirs()
|
||||
createNewFile()
|
||||
}
|
||||
|
||||
private fun TemporaryFolder.createRustMediaFile() = File(this.root, "rustMediaFile.ogg").apply { createNewFile() }
|
||||
|
|
@ -1,307 +0,0 @@
|
|||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages.timeline
|
||||
|
||||
import app.cash.turbine.TurbineTestContext
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultVoiceMessagePlayerTest {
|
||||
@Test
|
||||
fun `initial state`() = runTest {
|
||||
createDefaultVoiceMessagePlayer().state.test {
|
||||
matchInitialState()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepare succeeds`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer()
|
||||
player.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player.prepare().isSuccess).isTrue()
|
||||
matchReadyState()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepare fails when repo fails`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer(
|
||||
voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply {
|
||||
shouldFail = true
|
||||
},
|
||||
)
|
||||
player.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player.prepare().isFailure).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepare fails with no eventId`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer(
|
||||
eventId = null
|
||||
)
|
||||
player.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player.prepare().isFailure).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `play after prepare works`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer()
|
||||
player.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player.prepare().isSuccess).isTrue()
|
||||
matchReadyState()
|
||||
player.play()
|
||||
awaitItem().let {
|
||||
assertThat(it.isPlaying).isTrue()
|
||||
assertThat(it.currentPosition).isEqualTo(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `play reaches end of media`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer(
|
||||
mediaPlayer = FakeMediaPlayer(
|
||||
fakeTotalDurationMs = 1000,
|
||||
fakePlayedDurationMs = 1000
|
||||
)
|
||||
)
|
||||
player.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player.prepare().isSuccess).isTrue()
|
||||
matchReadyState(fakeTotalDurationMs = 1000)
|
||||
player.play()
|
||||
awaitItem().let {
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isTrue()
|
||||
assertThat(it.currentPosition).isEqualTo(1000)
|
||||
assertThat(it.duration).isEqualTo(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `player1 plays again after both player1 and player2 are finished`() = runTest {
|
||||
val mediaPlayer = FakeMediaPlayer(
|
||||
fakeTotalDurationMs = 1_000L,
|
||||
fakePlayedDurationMs = 1_000L,
|
||||
)
|
||||
val player1 = createDefaultVoiceMessagePlayer(mediaPlayer = mediaPlayer)
|
||||
val player2 = createDefaultVoiceMessagePlayer(mediaPlayer = mediaPlayer)
|
||||
|
||||
// Play player1 until the end.
|
||||
player1.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player1.prepare().isSuccess).isTrue()
|
||||
matchReadyState(1_000L)
|
||||
player1.play()
|
||||
awaitItem().let { // it plays until the end.
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isTrue()
|
||||
assertThat(it.currentPosition).isEqualTo(1000)
|
||||
assertThat(it.duration).isEqualTo(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Play player2 until the end.
|
||||
player2.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player2.prepare().isSuccess).isTrue()
|
||||
awaitItem().let { // Additional spurious state due to MediaPlayer owner change.
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isTrue()
|
||||
assertThat(it.currentPosition).isEqualTo(1000)
|
||||
assertThat(it.duration).isEqualTo(1000)
|
||||
}
|
||||
awaitItem().let { // Additional spurious state due to MediaPlayer owner change.
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isFalse()
|
||||
assertThat(it.currentPosition).isEqualTo(0)
|
||||
assertThat(it.duration).isNull()
|
||||
}
|
||||
matchReadyState(1_000L)
|
||||
player2.play()
|
||||
awaitItem().let { // it plays until the end.
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isTrue()
|
||||
assertThat(it.currentPosition).isEqualTo(1000)
|
||||
assertThat(it.duration).isEqualTo(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Play player1 again.
|
||||
player1.state.test {
|
||||
awaitItem().let { // Last previous state/
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isTrue()
|
||||
assertThat(it.currentPosition).isEqualTo(1000)
|
||||
assertThat(it.duration).isEqualTo(1000)
|
||||
}
|
||||
assertThat(player1.prepare().isSuccess).isTrue()
|
||||
awaitItem().let { // Additional spurious state due to MediaPlayer owner change.
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isFalse()
|
||||
assertThat(it.currentPosition).isEqualTo(0)
|
||||
assertThat(it.duration).isNull()
|
||||
}
|
||||
matchReadyState(1_000L)
|
||||
player1.play()
|
||||
awaitItem().let { // it played again until the end.
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isTrue()
|
||||
assertThat(it.currentPosition).isEqualTo(1000)
|
||||
assertThat(it.duration).isEqualTo(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pause after play pauses`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer()
|
||||
player.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player.prepare().isSuccess).isTrue()
|
||||
matchReadyState()
|
||||
player.play()
|
||||
skipItems(1) // skip play state
|
||||
player.pause()
|
||||
awaitItem().let {
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.currentPosition).isEqualTo(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `play after pause works`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer()
|
||||
player.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player.prepare().isSuccess).isTrue()
|
||||
matchReadyState()
|
||||
player.play()
|
||||
skipItems(1) // skip play state
|
||||
player.pause()
|
||||
skipItems(1) // skip pause state
|
||||
player.play()
|
||||
awaitItem().let {
|
||||
assertThat(it.isPlaying).isTrue()
|
||||
assertThat(it.currentPosition).isEqualTo(2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seek before prepare works`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer()
|
||||
player.state.test {
|
||||
matchInitialState()
|
||||
player.seekTo(2000)
|
||||
awaitItem().let {
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isFalse()
|
||||
assertThat(it.currentPosition).isEqualTo(2000)
|
||||
assertThat(it.duration).isNull()
|
||||
}
|
||||
assertThat(player.prepare().isSuccess).isTrue()
|
||||
awaitItem().let {
|
||||
assertThat(it.isReady).isTrue()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isFalse()
|
||||
assertThat(it.currentPosition).isEqualTo(2000)
|
||||
assertThat(it.duration).isEqualTo(FAKE_TOTAL_DURATION_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seek after prepare works`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer()
|
||||
player.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player.prepare().isSuccess).isTrue()
|
||||
matchReadyState()
|
||||
player.seekTo(2000)
|
||||
awaitItem().let {
|
||||
assertThat(it.isReady).isTrue()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isFalse()
|
||||
assertThat(it.currentPosition).isEqualTo(2000)
|
||||
assertThat(it.duration).isEqualTo(FAKE_TOTAL_DURATION_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val FAKE_TOTAL_DURATION_MS = 10_000L
|
||||
private const val FAKE_PLAYED_DURATION_MS = 1000L
|
||||
|
||||
private fun createDefaultVoiceMessagePlayer(
|
||||
mediaPlayer: MediaPlayer = FakeMediaPlayer(
|
||||
fakeTotalDurationMs = FAKE_TOTAL_DURATION_MS,
|
||||
fakePlayedDurationMs = FAKE_PLAYED_DURATION_MS
|
||||
),
|
||||
voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(),
|
||||
eventId: EventId? = AN_EVENT_ID,
|
||||
) = DefaultVoiceMessagePlayer(
|
||||
mediaPlayer = mediaPlayer,
|
||||
voiceMessageMediaRepoFactory = { _, _, _ -> voiceMessageMediaRepo },
|
||||
eventId = eventId,
|
||||
mediaSource = MediaSource(
|
||||
url = MXC_URI,
|
||||
json = null
|
||||
),
|
||||
mimeType = MimeTypes.Ogg,
|
||||
filename = "someBody.ogg"
|
||||
)
|
||||
|
||||
private const val MXC_URI = "mxc://matrix.org/1234567890abcdefg"
|
||||
|
||||
private suspend fun TurbineTestContext<VoiceMessagePlayer.State>.matchInitialState() {
|
||||
awaitItem().let {
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isFalse()
|
||||
assertThat(it.currentPosition).isEqualTo(0)
|
||||
assertThat(it.duration).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun TurbineTestContext<VoiceMessagePlayer.State>.matchReadyState(
|
||||
fakeTotalDurationMs: Long = FAKE_TOTAL_DURATION_MS,
|
||||
) {
|
||||
awaitItem().let {
|
||||
assertThat(it.isReady).isTrue()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isFalse()
|
||||
assertThat(it.currentPosition).isEqualTo(0)
|
||||
assertThat(it.duration).isEqualTo(fakeTotalDurationMs)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages.timeline
|
||||
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A fake implementation of [VoiceMessageMediaRepo] for testing purposes.
|
||||
*/
|
||||
class FakeVoiceMessageMediaRepo : VoiceMessageMediaRepo {
|
||||
var shouldFail = false
|
||||
|
||||
override suspend fun getMediaFile(): Result<File> = simulateLongTask {
|
||||
if (shouldFail) {
|
||||
Result.failure(IllegalStateException("Failed to get media file"))
|
||||
} else {
|
||||
Result.success(File(""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages.timeline
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
|
||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class VoiceMessagePresenterTest {
|
||||
@Test
|
||||
fun `initial state has proper default values`() = runTest {
|
||||
val presenter = createVoiceMessagePresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().let {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("1:01")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pressing play downloads and plays`() = runTest {
|
||||
val presenter = createVoiceMessagePresenter(
|
||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||
content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("0:02")
|
||||
}
|
||||
|
||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
||||
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("0:02")
|
||||
}
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("0:00")
|
||||
}
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
||||
assertThat(it.progress).isEqualTo(0.5f)
|
||||
assertThat(it.time).isEqualTo("0:01")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pressing play downloads and fails`() = runTest {
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val presenter = createVoiceMessagePresenter(
|
||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||
voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { shouldFail = true },
|
||||
analyticsService = analyticsService,
|
||||
content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("0:02")
|
||||
}
|
||||
|
||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
||||
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("0:02")
|
||||
}
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Retry)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("0:02")
|
||||
}
|
||||
analyticsService.trackedErrors.first().also {
|
||||
assertThat(it).apply {
|
||||
isInstanceOf(VoiceMessageException.PlayMessageError::class.java)
|
||||
hasMessageThat().isEqualTo("Error while trying to play voice message")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pressing pause while playing pauses`() = runTest {
|
||||
val presenter = createVoiceMessagePresenter(
|
||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||
content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("0:02")
|
||||
}
|
||||
|
||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
||||
skipItems(2) // skip downloading states
|
||||
|
||||
val playingState = awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
||||
assertThat(it.progress).isEqualTo(0.5f)
|
||||
assertThat(it.time).isEqualTo("0:01")
|
||||
}
|
||||
|
||||
playingState.eventSink(VoiceMessageEvents.PlayPause)
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
||||
assertThat(it.progress).isEqualTo(0.5f)
|
||||
assertThat(it.time).isEqualTo("0:01")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `content with null eventId shows disabled button`() = runTest {
|
||||
val presenter = createVoiceMessagePresenter(
|
||||
content = aTimelineItemVoiceContent(eventId = null),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Disabled)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("1:01")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seeking before play`() = runTest {
|
||||
val presenter = createVoiceMessagePresenter(
|
||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||
content = aTimelineItemVoiceContent(duration = 10_000.milliseconds),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("0:10")
|
||||
}
|
||||
|
||||
initialState.eventSink(VoiceMessageEvents.Seek(0.5f))
|
||||
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
||||
assertThat(it.progress).isEqualTo(0.5f)
|
||||
assertThat(it.time).isEqualTo("0:05")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seeking after play`() = runTest {
|
||||
val presenter = createVoiceMessagePresenter(
|
||||
content = aTimelineItemVoiceContent(duration = 10_000.milliseconds),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("0:10")
|
||||
}
|
||||
|
||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
||||
|
||||
skipItems(2) // skip downloading states
|
||||
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
||||
assertThat(it.progress).isEqualTo(0.1f)
|
||||
assertThat(it.time).isEqualTo("0:01")
|
||||
}
|
||||
|
||||
initialState.eventSink(VoiceMessageEvents.Seek(0.5f))
|
||||
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
||||
assertThat(it.progress).isEqualTo(0.5f)
|
||||
assertThat(it.time).isEqualTo("0:05")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun TestScope.createVoiceMessagePresenter(
|
||||
mediaPlayer: FakeMediaPlayer = FakeMediaPlayer(),
|
||||
voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(),
|
||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||
content: TimelineItemVoiceContent = aTimelineItemVoiceContent(),
|
||||
) = VoiceMessagePresenter(
|
||||
voiceMessagePlayerFactory = { eventId, mediaSource, mimeType, filename ->
|
||||
DefaultVoiceMessagePlayer(
|
||||
mediaPlayer = mediaPlayer,
|
||||
voiceMessageMediaRepoFactory = { _, _, _ -> voiceMessageMediaRepo },
|
||||
eventId = eventId,
|
||||
mediaSource = mediaSource,
|
||||
mimeType = mimeType,
|
||||
filename = filename
|
||||
)
|
||||
},
|
||||
analyticsService = analyticsService,
|
||||
scope = this,
|
||||
content = content,
|
||||
)
|
||||
|
|
@ -9,7 +9,8 @@ package io.element.android.features.poll.impl.history.model
|
|||
|
||||
import io.element.android.features.poll.api.pollcontent.PollContentStateFactory
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.dateformatter.api.DaySeparatorFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
|
@ -18,7 +19,7 @@ import javax.inject.Inject
|
|||
|
||||
class PollHistoryItemsFactory @Inject constructor(
|
||||
private val pollContentStateFactory: PollContentStateFactory,
|
||||
private val daySeparatorFormatter: DaySeparatorFormatter,
|
||||
private val dateFormatter: DateFormatter,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
suspend fun create(timelineItems: List<MatrixTimelineItem>): PollHistoryItems = withContext(dispatchers.computation) {
|
||||
|
|
@ -45,7 +46,11 @@ class PollHistoryItemsFactory @Inject constructor(
|
|||
val pollContent = timelineItem.event.content as? PollContent ?: return null
|
||||
val pollContentState = pollContentStateFactory.create(timelineItem.event, pollContent)
|
||||
PollHistoryItem(
|
||||
formattedDate = daySeparatorFormatter.format(timelineItem.event.timestamp),
|
||||
formattedDate = dateFormatter.format(
|
||||
timestamp = timelineItem.event.timestamp,
|
||||
mode = DateFormatterMode.Day,
|
||||
useRelative = true
|
||||
),
|
||||
state = pollContentState
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import io.element.android.features.poll.impl.history.model.PollHistoryItemsFacto
|
|||
import io.element.android.features.poll.impl.model.DefaultPollContentStateFactory
|
||||
import io.element.android.features.poll.test.actions.FakeEndPollAction
|
||||
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
|
||||
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
|
||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
|
|
@ -161,7 +161,7 @@ class PollHistoryPresenterTest {
|
|||
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
|
||||
pollHistoryItemFactory: PollHistoryItemsFactory = PollHistoryItemsFactory(
|
||||
pollContentStateFactory = DefaultPollContentStateFactory(FakeMatrixClient()),
|
||||
daySeparatorFormatter = FakeDaySeparatorFormatter(),
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
dispatchers = testCoroutineDispatchers(),
|
||||
),
|
||||
): PollHistoryPresenter {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import io.element.android.features.logout.api.LogoutUseCase
|
|||
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
|
||||
import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase
|
||||
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
|
|
@ -63,7 +64,7 @@ class DeveloperSettingsPresenter @Inject constructor(
|
|||
mutableStateOf<AsyncData<String>>(AsyncData.Uninitialized)
|
||||
}
|
||||
val clearCacheAction = remember {
|
||||
mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized)
|
||||
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
||||
}
|
||||
val customElementCallBaseUrl by appPreferencesStore
|
||||
.getCustomElementCallBaseUrlFlow()
|
||||
|
|
@ -94,7 +95,7 @@ class DeveloperSettingsPresenter @Inject constructor(
|
|||
val featureUiModels = createUiModels(features, enabledFeatures)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
// Compute cache size each time the clear cache action value is changed
|
||||
LaunchedEffect(clearCacheAction.value) {
|
||||
LaunchedEffect(clearCacheAction.value.isSuccess()) {
|
||||
computeCacheSize(cacheSize)
|
||||
}
|
||||
|
||||
|
|
@ -180,7 +181,7 @@ class DeveloperSettingsPresenter @Inject constructor(
|
|||
}.runCatchingUpdatingState(cacheSize)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.clearCache(clearCacheAction: MutableState<AsyncData<Unit>>) = launch {
|
||||
private fun CoroutineScope.clearCache(clearCacheAction: MutableState<AsyncAction<Unit>>) = launch {
|
||||
suspend {
|
||||
clearCacheUseCase()
|
||||
}.runCatchingUpdatingState(clearCacheAction)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
package io.element.android.features.preferences.impl.developer
|
||||
|
||||
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
|
@ -16,7 +17,7 @@ data class DeveloperSettingsState(
|
|||
val features: ImmutableList<FeatureUiModel>,
|
||||
val cacheSize: AsyncData<String>,
|
||||
val rageshakeState: RageshakePreferencesState,
|
||||
val clearCacheAction: AsyncData<Unit>,
|
||||
val clearCacheAction: AsyncAction<Unit>,
|
||||
val customElementCallBaseUrlState: CustomElementCallBaseUrlState,
|
||||
val isSimpleSlidingSyncEnabled: Boolean,
|
||||
val hideImagesAndVideos: Boolean,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.developer
|
|||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList
|
||||
|
||||
|
|
@ -17,7 +18,7 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSe
|
|||
get() = sequenceOf(
|
||||
aDeveloperSettingsState(),
|
||||
aDeveloperSettingsState(
|
||||
clearCacheAction = AsyncData.Loading()
|
||||
clearCacheAction = AsyncAction.Loading
|
||||
),
|
||||
aDeveloperSettingsState(
|
||||
customElementCallBaseUrlState = aCustomElementCallBaseUrlState(
|
||||
|
|
@ -28,7 +29,7 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSe
|
|||
}
|
||||
|
||||
fun aDeveloperSettingsState(
|
||||
clearCacheAction: AsyncData<Unit> = AsyncData.Uninitialized,
|
||||
clearCacheAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(),
|
||||
isSimplifiedSlidingSyncEnabled: Boolean = false,
|
||||
hideImagesAndVideos: Boolean = false,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import io.element.android.features.preferences.impl.user.UserPreferences
|
|||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
|
||||
|
|
@ -33,7 +34,6 @@ import io.element.android.libraries.designsystem.theme.components.IconSource
|
|||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
|
|
@ -270,7 +270,7 @@ private fun ColumnScope.Footer(
|
|||
private fun DeveloperPreferencesView(onOpenDeveloperSettings: () -> Unit) {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(id = CommonStrings.common_developer_options)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_developer_options)),
|
||||
leadingContent = ListItemContent.Icon(IconSource.Resource(CompoundDrawables.ic_compound_code)),
|
||||
onClick = onOpenDeveloperSettings
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,17 +5,17 @@
|
|||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.preferences.impl.developer
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.appconfig.ElementCallConfig
|
||||
import io.element.android.features.logout.test.FakeLogoutUseCase
|
||||
import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase
|
||||
import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase
|
||||
import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.core.meta.BuildType
|
||||
|
|
@ -24,8 +24,8 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
|||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
|
|
@ -38,37 +38,29 @@ class DeveloperSettingsPresenterTest {
|
|||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `present - ensures initial state is correct`() = runTest {
|
||||
fun `present - ensures initial states are correct`() = runTest {
|
||||
val presenter = createDeveloperSettingsPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.features).isEmpty()
|
||||
assertThat(initialState.clearCacheAction).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(initialState.cacheSize).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(initialState.customElementCallBaseUrlState).isNotNull()
|
||||
assertThat(initialState.customElementCallBaseUrlState.baseUrl).isNull()
|
||||
assertThat(initialState.isSimpleSlidingSyncEnabled).isFalse()
|
||||
assertThat(initialState.hideImagesAndVideos).isFalse()
|
||||
val loadedState = awaitItem()
|
||||
assertThat(loadedState.rageshakeState.isEnabled).isFalse()
|
||||
assertThat(loadedState.rageshakeState.isSupported).isTrue()
|
||||
assertThat(loadedState.rageshakeState.sensitivity).isEqualTo(0.3f)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - ensures feature list is loaded`() = runTest {
|
||||
val presenter = createDeveloperSettingsPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = awaitLastSequentialItem()
|
||||
val numberOfModifiableFeatureFlags = FeatureFlags.entries.count { it.isFinished.not() }
|
||||
assertThat(state.features).hasSize(numberOfModifiableFeatureFlags)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
presenter.test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.features).isEmpty()
|
||||
assertThat(state.clearCacheAction).isEqualTo(AsyncAction.Uninitialized)
|
||||
assertThat(state.cacheSize).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(state.customElementCallBaseUrlState).isNotNull()
|
||||
assertThat(state.customElementCallBaseUrlState.baseUrl).isNull()
|
||||
assertThat(state.isSimpleSlidingSyncEnabled).isFalse()
|
||||
assertThat(state.hideImagesAndVideos).isFalse()
|
||||
assertThat(state.rageshakeState.isEnabled).isFalse()
|
||||
assertThat(state.rageshakeState.isSupported).isTrue()
|
||||
assertThat(state.rageshakeState.sensitivity).isEqualTo(0.3f)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.features).isNotEmpty()
|
||||
val numberOfModifiableFeatureFlags = FeatureFlags.entries.count { it.isFinished.not() }
|
||||
assertThat(state.features).hasSize(numberOfModifiableFeatureFlags)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.cacheSize).isInstanceOf(AsyncData.Success::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,30 +68,28 @@ class DeveloperSettingsPresenterTest {
|
|||
fun `present - ensures Room directory search is not present on release Google Play builds`() = runTest {
|
||||
val buildMeta = aBuildMeta(buildType = BuildType.RELEASE, flavorDescription = "GooglePlay")
|
||||
val presenter = createDeveloperSettingsPresenter(buildMeta = buildMeta)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = awaitLastSequentialItem()
|
||||
assertThat(state.features).doesNotContain(FeatureFlags.RoomDirectorySearch)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.features).doesNotContain(FeatureFlags.RoomDirectorySearch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - ensures state is updated when enabled feature event is triggered`() = runTest {
|
||||
val presenter = createDeveloperSettingsPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val stateBeforeEvent = awaitItem()
|
||||
val featureBeforeEvent = stateBeforeEvent.features.first()
|
||||
stateBeforeEvent.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(featureBeforeEvent, !featureBeforeEvent.isEnabled))
|
||||
val stateAfterEvent = awaitItem()
|
||||
val featureAfterEvent = stateAfterEvent.features.first()
|
||||
assertThat(featureBeforeEvent.key).isEqualTo(featureAfterEvent.key)
|
||||
assertThat(featureBeforeEvent.isEnabled).isNotEqualTo(featureAfterEvent.isEnabled)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
val feature = state.features.first()
|
||||
state.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(feature, !feature.isEnabled))
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
val feature = state.features.first()
|
||||
assertThat(feature.isEnabled).isTrue()
|
||||
assertThat(feature.key).isEqualTo(feature.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,19 +97,25 @@ class DeveloperSettingsPresenterTest {
|
|||
fun `present - clear cache`() = runTest {
|
||||
val clearCacheUseCase = FakeClearCacheUseCase()
|
||||
val presenter = createDeveloperSettingsPresenter(clearCacheUseCase = clearCacheUseCase)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
assertThat(clearCacheUseCase.executeHasBeenCalled).isFalse()
|
||||
initialState.eventSink(DeveloperSettingsEvents.ClearCache)
|
||||
val stateAfterEvent = awaitItem()
|
||||
assertThat(stateAfterEvent.clearCacheAction).isInstanceOf(AsyncData.Loading::class.java)
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().clearCacheAction).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(clearCacheUseCase.executeHasBeenCalled).isTrue()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
awaitItem().also { state ->
|
||||
state.eventSink(DeveloperSettingsEvents.ClearCache)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.clearCacheAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.clearCacheAction).isInstanceOf(AsyncAction.Success::class.java)
|
||||
assertThat(clearCacheUseCase.executeHasBeenCalled).isTrue()
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.cacheSize).isInstanceOf(AsyncData.Loading::class.java)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.cacheSize).isInstanceOf(AsyncData.Success::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -127,26 +123,25 @@ class DeveloperSettingsPresenterTest {
|
|||
fun `present - custom element call base url`() = runTest {
|
||||
val preferencesStore = InMemoryAppPreferencesStore()
|
||||
val presenter = createDeveloperSettingsPresenter(preferencesStore = preferencesStore)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.customElementCallBaseUrlState.baseUrl).isNull()
|
||||
initialState.eventSink(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.ahoy"))
|
||||
val updatedItem = awaitItem()
|
||||
assertThat(updatedItem.customElementCallBaseUrlState.baseUrl).isEqualTo("https://call.element.ahoy")
|
||||
assertThat(updatedItem.customElementCallBaseUrlState.defaultUrl).isEqualTo(ElementCallConfig.DEFAULT_BASE_URL)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.customElementCallBaseUrlState.baseUrl).isNull()
|
||||
state.eventSink(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.ahoy"))
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.customElementCallBaseUrlState.baseUrl).isEqualTo("https://call.element.ahoy")
|
||||
assertThat(state.customElementCallBaseUrlState.defaultUrl).isEqualTo(ElementCallConfig.DEFAULT_BASE_URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - custom element call base url validator needs at least an HTTP scheme and host`() = runTest {
|
||||
val presenter = createDeveloperSettingsPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val urlValidator = awaitLastSequentialItem().customElementCallBaseUrlState.validator
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
val urlValidator = awaitItem().customElementCallBaseUrlState.validator
|
||||
assertThat(urlValidator("")).isTrue() // We allow empty string to clear the value and use the default one
|
||||
assertThat(urlValidator("test")).isFalse()
|
||||
assertThat(urlValidator("http://")).isFalse()
|
||||
|
|
@ -155,30 +150,31 @@ class DeveloperSettingsPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - toggling simplified sliding sync changes the preferences and logs out the user`() = runTest {
|
||||
val logoutCallRecorder = lambdaRecorder<Boolean, String?> { "" }
|
||||
val logoutUseCase = FakeLogoutUseCase(logoutLambda = logoutCallRecorder)
|
||||
val preferences = InMemoryAppPreferencesStore()
|
||||
val presenter = createDeveloperSettingsPresenter(preferencesStore = preferences, logoutUseCase = logoutUseCase)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitLastSequentialItem()
|
||||
assertThat(initialState.isSimpleSlidingSyncEnabled).isFalse()
|
||||
|
||||
initialState.eventSink(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(true))
|
||||
assertThat(awaitItem().isSimpleSlidingSyncEnabled).isTrue()
|
||||
assertThat(preferences.isSimplifiedSlidingSyncEnabledFlow().first()).isTrue()
|
||||
advanceUntilIdle()
|
||||
logoutCallRecorder.assertions().isCalledOnce()
|
||||
|
||||
initialState.eventSink(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(false))
|
||||
assertThat(awaitItem().isSimpleSlidingSyncEnabled).isFalse()
|
||||
assertThat(preferences.isSimplifiedSlidingSyncEnabledFlow().first()).isFalse()
|
||||
advanceUntilIdle()
|
||||
logoutCallRecorder.assertions().isCalledExactly(times = 2)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isSimpleSlidingSyncEnabled).isFalse()
|
||||
state.eventSink(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(true))
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isSimpleSlidingSyncEnabled).isTrue()
|
||||
assertThat(preferences.isSimplifiedSlidingSyncEnabledFlow().first()).isTrue()
|
||||
advanceUntilIdle()
|
||||
logoutCallRecorder.assertions().isCalledOnce()
|
||||
state.eventSink(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(false))
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.isSimpleSlidingSyncEnabled).isFalse()
|
||||
assertThat(preferences.isSimplifiedSlidingSyncEnabledFlow().first()).isFalse()
|
||||
advanceUntilIdle()
|
||||
logoutCallRecorder.assertions().isCalledExactly(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -186,17 +182,21 @@ class DeveloperSettingsPresenterTest {
|
|||
fun `present - toggling hide image and video`() = runTest {
|
||||
val preferences = InMemoryAppPreferencesStore()
|
||||
val presenter = createDeveloperSettingsPresenter(preferencesStore = preferences)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitLastSequentialItem()
|
||||
assertThat(initialState.hideImagesAndVideos).isFalse()
|
||||
initialState.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(true))
|
||||
assertThat(awaitItem().hideImagesAndVideos).isTrue()
|
||||
assertThat(preferences.doesHideImagesAndVideosFlow().first()).isTrue()
|
||||
initialState.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(false))
|
||||
assertThat(awaitItem().hideImagesAndVideos).isFalse()
|
||||
assertThat(preferences.doesHideImagesAndVideosFlow().first()).isFalse()
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.hideImagesAndVideos).isFalse()
|
||||
state.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(true))
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.hideImagesAndVideos).isTrue()
|
||||
assertThat(preferences.doesHideImagesAndVideosFlow().first()).isTrue()
|
||||
state.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(false))
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.hideImagesAndVideos).isFalse()
|
||||
assertThat(preferences.doesHideImagesAndVideosFlow().first()).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@
|
|||
<string name="screen_room_details_requests_to_join_title">"Žádosti o vstup"</string>
|
||||
<string name="screen_room_details_roles_and_permissions">"Role a oprávnění"</string>
|
||||
<string name="screen_room_details_room_name_label">"Název místnosti"</string>
|
||||
<string name="screen_room_details_security_and_privacy_title">"Zabezpečení a soukromí"</string>
|
||||
<string name="screen_room_details_security_title">"Zabezpečení"</string>
|
||||
<string name="screen_room_details_share_room_title">"Sdílet místnost"</string>
|
||||
<string name="screen_room_details_title">"Informace o místnosti"</string>
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@
|
|||
<string name="screen_room_details_invite_people_title">"Πρόσκληση ατόμων"</string>
|
||||
<string name="screen_room_details_leave_conversation_title">"Αποχώρηση από τη συζήτηση"</string>
|
||||
<string name="screen_room_details_leave_room_title">"Αποχώρηση από το δωμάτιο"</string>
|
||||
<string name="screen_room_details_media_gallery_title">"Πολυμέσα και αρχεία"</string>
|
||||
<string name="screen_room_details_notification_mode_custom">"Προσαρμοσμένο"</string>
|
||||
<string name="screen_room_details_notification_mode_default">"Προεπιλογή"</string>
|
||||
<string name="screen_room_details_notification_title">"Ειδοποιήσεις"</string>
|
||||
|
|
@ -56,6 +57,7 @@
|
|||
<string name="screen_room_details_requests_to_join_title">"Αιτήματα συμμετοχής"</string>
|
||||
<string name="screen_room_details_roles_and_permissions">"Ρόλοι και δικαιώματα"</string>
|
||||
<string name="screen_room_details_room_name_label">"Όνομα δωματίου"</string>
|
||||
<string name="screen_room_details_security_and_privacy_title">"Ασφάλεια & απόρρητο"</string>
|
||||
<string name="screen_room_details_security_title">"Ασφάλεια"</string>
|
||||
<string name="screen_room_details_share_room_title">"Κοινή χρήση δωματίου"</string>
|
||||
<string name="screen_room_details_title">"Πληροφορίες δωματίου"</string>
|
||||
|
|
|
|||
|
|
@ -49,10 +49,12 @@
|
|||
<string name="screen_room_details_invite_people_title">"Kutsu ihmisiä"</string>
|
||||
<string name="screen_room_details_leave_conversation_title">"Poistu keskustelusta"</string>
|
||||
<string name="screen_room_details_leave_room_title">"Poistu huoneesta"</string>
|
||||
<string name="screen_room_details_media_gallery_title">"Media ja tiedostot"</string>
|
||||
<string name="screen_room_details_notification_mode_custom">"Mukautettu"</string>
|
||||
<string name="screen_room_details_notification_mode_default">"Oletus"</string>
|
||||
<string name="screen_room_details_notification_title">"Ilmoitukset"</string>
|
||||
<string name="screen_room_details_pinned_events_row_title">"Kiinnitetyt viestit"</string>
|
||||
<string name="screen_room_details_requests_to_join_title">"Liittymispyynnöt"</string>
|
||||
<string name="screen_room_details_roles_and_permissions">"Roolit ja oikeudet"</string>
|
||||
<string name="screen_room_details_room_name_label">"Huoneen nimi"</string>
|
||||
<string name="screen_room_details_security_title">"Turvallisuus"</string>
|
||||
|
|
@ -80,7 +82,7 @@
|
|||
<string name="screen_room_member_list_manage_member_user_info">"Näytä profiili"</string>
|
||||
<string name="screen_room_member_list_mode_banned">"Porttikiellot"</string>
|
||||
<string name="screen_room_member_list_mode_members">"Jäsenet"</string>
|
||||
<string name="screen_room_member_list_pending_header_title">"Kutsutut"</string>
|
||||
<string name="screen_room_member_list_pending_header_title">"Kutsuttu"</string>
|
||||
<string name="screen_room_member_list_removing_user">"Poistetaan käyttäjää %1$s huoneesta…"</string>
|
||||
<string name="screen_room_member_list_role_administrator">"Ylläpitäjä"</string>
|
||||
<string name="screen_room_member_list_role_moderator">"Valvoja"</string>
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@
|
|||
<string name="screen_room_details_requests_to_join_title">"Demandes en attente"</string>
|
||||
<string name="screen_room_details_roles_and_permissions">"Rôles et autorisations"</string>
|
||||
<string name="screen_room_details_room_name_label">"Nom du salon"</string>
|
||||
<string name="screen_room_details_security_and_privacy_title">"Sécurité & confidentialité"</string>
|
||||
<string name="screen_room_details_security_title">"Sécurité"</string>
|
||||
<string name="screen_room_details_share_room_title">"Partager le salon"</string>
|
||||
<string name="screen_room_details_title">"Informations du salon"</string>
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@
|
|||
<string name="screen_room_details_requests_to_join_title">"Csatlakozási kérelem"</string>
|
||||
<string name="screen_room_details_roles_and_permissions">"Szerepkörök és jogosultságok"</string>
|
||||
<string name="screen_room_details_room_name_label">"Szoba neve"</string>
|
||||
<string name="screen_room_details_security_and_privacy_title">"Biztonság és adatvédelem"</string>
|
||||
<string name="screen_room_details_security_title">"Biztonság"</string>
|
||||
<string name="screen_room_details_share_room_title">"Szoba megosztása"</string>
|
||||
<string name="screen_room_details_title">"Szobainformációk"</string>
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@
|
|||
<string name="screen_room_details_requests_to_join_title">"Requests to join"</string>
|
||||
<string name="screen_room_details_roles_and_permissions">"Roles and permissions"</string>
|
||||
<string name="screen_room_details_room_name_label">"Room name"</string>
|
||||
<string name="screen_room_details_security_and_privacy_title">"Security & privacy"</string>
|
||||
<string name="screen_room_details_security_title">"Security"</string>
|
||||
<string name="screen_room_details_share_room_title">"Share room"</string>
|
||||
<string name="screen_room_details_title">"Room info"</string>
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ fun RoomListContextMenu(
|
|||
onFavoriteChange = { isFavorite ->
|
||||
eventSink(RoomListEvents.SetRoomIsFavorite(contextMenu.roomId, isFavorite))
|
||||
},
|
||||
onClearCacheRoomClick = {
|
||||
eventSink(RoomListEvents.HideContextMenu)
|
||||
eventSink(RoomListEvents.ClearCacheOfRoom(contextMenu.roomId))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -73,6 +77,7 @@ private fun RoomListModalBottomSheetContent(
|
|||
onFavoriteChange: (isFavorite: Boolean) -> Unit,
|
||||
onRoomMarkReadClick: () -> Unit,
|
||||
onRoomMarkUnreadClick: () -> Unit,
|
||||
onClearCacheRoomClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
|
|
@ -177,6 +182,18 @@ private fun RoomListModalBottomSheetContent(
|
|||
),
|
||||
style = ListItemStyle.Destructive,
|
||||
)
|
||||
if (contextMenu.eventCacheFeatureFlagEnabled) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(text = "Clear cache for this room")
|
||||
},
|
||||
modifier = Modifier.clickable { onClearCacheRoomClick() },
|
||||
leadingContent = ListItemContent.Icon(
|
||||
iconSource = IconSource.Vector(CompoundIcons.Delete())
|
||||
),
|
||||
style = ListItemStyle.Primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -195,5 +212,6 @@ internal fun RoomListModalBottomSheetContentPreview(
|
|||
onRoomSettingsClick = {},
|
||||
onLeaveRoomClick = {},
|
||||
onFavoriteChange = {},
|
||||
onClearCacheRoomClick = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,4 +25,5 @@ sealed interface RoomListEvents {
|
|||
data class MarkAsRead(val roomId: RoomId) : ContextMenuEvents
|
||||
data class MarkAsUnread(val roomId: RoomId) : ContextMenuEvents
|
||||
data class SetRoomIsFavorite(val roomId: RoomId, val isFavorite: Boolean) : ContextMenuEvents
|
||||
data class ClearCacheOfRoom(val roomId: RoomId) : ContextMenuEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ class RoomListPresenter @Inject constructor(
|
|||
AcceptDeclineInviteEvents.DeclineInvite(event.roomListRoomSummary.toInviteData())
|
||||
)
|
||||
}
|
||||
is RoomListEvents.ClearCacheOfRoom -> coroutineScope.clearCacheOfRoom(event.roomId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -255,7 +256,8 @@ class RoomListPresenter @Inject constructor(
|
|||
isDm = event.roomListRoomSummary.isDm,
|
||||
isFavorite = event.roomListRoomSummary.isFavorite,
|
||||
markAsUnreadFeatureFlagEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.MarkAsUnread),
|
||||
hasNewContent = event.roomListRoomSummary.hasNewContent
|
||||
hasNewContent = event.roomListRoomSummary.hasNewContent,
|
||||
eventCacheFeatureFlagEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.EventCache),
|
||||
)
|
||||
contextMenuState.value = initialState
|
||||
|
||||
|
|
@ -312,6 +314,12 @@ class RoomListPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.clearCacheOfRoom(roomId: RoomId) = launch {
|
||||
client.getRoom(roomId)?.use { room ->
|
||||
room.clearEventCacheStorage()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user needs to migrate to a native sliding sync version.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ data class RoomListState(
|
|||
val isDm: Boolean,
|
||||
val isFavorite: Boolean,
|
||||
val markAsUnreadFeatureFlagEnabled: Boolean,
|
||||
val eventCacheFeatureFlagEnabled: Boolean,
|
||||
val hasNewContent: Boolean,
|
||||
) : ContextMenu
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,4 +31,5 @@ internal fun aContextMenuShown(
|
|||
markAsUnreadFeatureFlagEnabled = true,
|
||||
hasNewContent = hasNewContent,
|
||||
isFavorite = isFavorite,
|
||||
eventCacheFeatureFlagEnabled = false,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ package io.element.android.features.roomlist.impl.datasource
|
|||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
|
||||
import io.element.android.libraries.core.extensions.orEmpty
|
||||
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
|
||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||
|
|
@ -22,7 +23,7 @@ import kotlinx.collections.immutable.toImmutableList
|
|||
import javax.inject.Inject
|
||||
|
||||
class RoomListRoomSummaryFactory @Inject constructor(
|
||||
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
|
||||
private val dateFormatter: DateFormatter,
|
||||
private val roomLastMessageFormatter: RoomLastMessageFormatter,
|
||||
) {
|
||||
fun create(roomSummary: RoomSummary): RoomListRoomSummary {
|
||||
|
|
@ -36,7 +37,11 @@ class RoomListRoomSummaryFactory @Inject constructor(
|
|||
numberOfUnreadMentions = roomInfo.numUnreadMentions,
|
||||
numberOfUnreadNotifications = roomInfo.numUnreadNotifications,
|
||||
isMarkedUnread = roomInfo.isMarkedUnread,
|
||||
timestamp = lastMessageTimestampFormatter.format(roomSummary.lastMessageTimestamp),
|
||||
timestamp = dateFormatter.format(
|
||||
timestamp = roomSummary.lastMessageTimestamp,
|
||||
mode = DateFormatterMode.TimeOrDate,
|
||||
useRelative = true,
|
||||
),
|
||||
lastMessage = roomSummary.lastMessage?.let { message ->
|
||||
roomLastMessageFormatter.format(message.event, roomInfo.isDm)
|
||||
}.orEmpty(),
|
||||
|
|
|
|||
|
|
@ -31,9 +31,8 @@ import io.element.android.features.roomlist.impl.search.RoomListSearchState
|
|||
import io.element.android.features.roomlist.impl.search.aRoomListSearchState
|
||||
import io.element.android.libraries.androidutils.system.DateTimeObserver
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
||||
import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE
|
||||
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
|
||||
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
|
||||
|
|
@ -188,6 +187,7 @@ class RoomListPresenterTest {
|
|||
createRoomListRoomSummary(
|
||||
numberOfUnreadMentions = 1,
|
||||
numberOfUnreadMessages = 2,
|
||||
timestamp = "0 TimeOrDate true",
|
||||
)
|
||||
)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
|
|
@ -288,6 +288,7 @@ class RoomListPresenterTest {
|
|||
isDm = false,
|
||||
isFavorite = false,
|
||||
markAsUnreadFeatureFlagEnabled = true,
|
||||
eventCacheFeatureFlagEnabled = false,
|
||||
hasNewContent = false,
|
||||
)
|
||||
)
|
||||
|
|
@ -305,6 +306,7 @@ class RoomListPresenterTest {
|
|||
isDm = false,
|
||||
isFavorite = true,
|
||||
markAsUnreadFeatureFlagEnabled = true,
|
||||
eventCacheFeatureFlagEnabled = false,
|
||||
hasNewContent = false,
|
||||
)
|
||||
)
|
||||
|
|
@ -335,6 +337,7 @@ class RoomListPresenterTest {
|
|||
isDm = false,
|
||||
isFavorite = false,
|
||||
markAsUnreadFeatureFlagEnabled = true,
|
||||
eventCacheFeatureFlagEnabled = false,
|
||||
hasNewContent = false,
|
||||
)
|
||||
)
|
||||
|
|
@ -633,9 +636,7 @@ class RoomListPresenterTest {
|
|||
networkMonitor: NetworkMonitor = FakeNetworkMonitor(),
|
||||
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
|
||||
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
|
||||
lastMessageTimestampFormatter: LastMessageTimestampFormatter = FakeLastMessageTimestampFormatter().apply {
|
||||
givenFormat(A_FORMATTED_DATE)
|
||||
},
|
||||
dateFormatter: DateFormatter = FakeDateFormatter(),
|
||||
roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(),
|
||||
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||
|
|
@ -652,7 +653,7 @@ class RoomListPresenterTest {
|
|||
roomListDataSource = RoomListDataSource(
|
||||
roomListService = client.roomListService,
|
||||
roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
|
||||
lastMessageTimestampFormatter = lastMessageTimestampFormatter,
|
||||
dateFormatter = dateFormatter,
|
||||
roomLastMessageFormatter = roomLastMessageFormatter,
|
||||
),
|
||||
coroutineDispatchers = testCoroutineDispatchers(),
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import app.cash.turbine.test
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.roomlist.impl.FakeDateTimeObserver
|
||||
import io.element.android.libraries.androidutils.system.DateTimeObserver
|
||||
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
|
||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
|
||||
import io.element.android.libraries.matrix.test.room.aRoomSummary
|
||||
|
|
@ -30,12 +30,12 @@ class RoomListDataSourceTest {
|
|||
postAllRooms(listOf(aRoomSummary()))
|
||||
}
|
||||
val dateTimeObserver = FakeDateTimeObserver()
|
||||
val lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter()
|
||||
lastMessageTimestampFormatter.givenFormat("Today")
|
||||
var dateFormatterResult = "Today"
|
||||
val dateFormatter = FakeDateFormatter({ _, _, _ -> dateFormatterResult })
|
||||
val roomListDataSource = createRoomListDataSource(
|
||||
roomListService = roomListService,
|
||||
roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
|
||||
lastMessageTimestampFormatter = lastMessageTimestampFormatter,
|
||||
dateFormatter = dateFormatter,
|
||||
),
|
||||
dateTimeObserver = dateTimeObserver,
|
||||
)
|
||||
|
|
@ -47,7 +47,7 @@ class RoomListDataSourceTest {
|
|||
val initialRoomList = awaitItem()
|
||||
assertThat(initialRoomList).isNotEmpty()
|
||||
assertThat(initialRoomList.first().timestamp).isEqualTo("Today")
|
||||
lastMessageTimestampFormatter.givenFormat("Yesterday")
|
||||
dateFormatterResult = "Yesterday"
|
||||
// Trigger a date change
|
||||
dateTimeObserver.given(DateTimeObserver.Event.DateChanged(Instant.MIN, Instant.now()))
|
||||
// Check there is a new list and it's not the same as the previous one
|
||||
|
|
@ -64,12 +64,12 @@ class RoomListDataSourceTest {
|
|||
postAllRooms(listOf(aRoomSummary()))
|
||||
}
|
||||
val dateTimeObserver = FakeDateTimeObserver()
|
||||
val lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter()
|
||||
lastMessageTimestampFormatter.givenFormat("Today")
|
||||
var dateFormatterResult = "Today"
|
||||
val dateFormatter = FakeDateFormatter({ _, _, _ -> dateFormatterResult })
|
||||
val roomListDataSource = createRoomListDataSource(
|
||||
roomListService = roomListService,
|
||||
roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
|
||||
lastMessageTimestampFormatter = lastMessageTimestampFormatter,
|
||||
dateFormatter = dateFormatter,
|
||||
),
|
||||
dateTimeObserver = dateTimeObserver,
|
||||
)
|
||||
|
|
@ -80,7 +80,7 @@ class RoomListDataSourceTest {
|
|||
val initialRoomList = awaitItem()
|
||||
assertThat(initialRoomList).isNotEmpty()
|
||||
assertThat(initialRoomList.first().timestamp).isEqualTo("Today")
|
||||
lastMessageTimestampFormatter.givenFormat("Yesterday")
|
||||
dateFormatterResult = "Yesterday"
|
||||
// Trigger a timezone change
|
||||
dateTimeObserver.given(DateTimeObserver.Event.TimeZoneChanged)
|
||||
// Check there is a new list and it's not the same as the previous one
|
||||
|
|
|
|||
|
|
@ -7,13 +7,14 @@
|
|||
|
||||
package io.element.android.features.roomlist.impl.datasource
|
||||
|
||||
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
|
||||
|
||||
fun aRoomListRoomSummaryFactory(
|
||||
lastMessageTimestampFormatter: LastMessageTimestampFormatter = LastMessageTimestampFormatter { _ -> "Today" },
|
||||
dateFormatter: DateFormatter = FakeDateFormatter { _, _, _ -> "Today" },
|
||||
roomLastMessageFormatter: RoomLastMessageFormatter = RoomLastMessageFormatter { _, _ -> "Hey" }
|
||||
) = RoomListRoomSummaryFactory(
|
||||
lastMessageTimestampFormatter = lastMessageTimestampFormatter,
|
||||
dateFormatter = dateFormatter,
|
||||
roomLastMessageFormatter = roomLastMessageFormatter
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
package io.element.android.features.roomlist.impl.model
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
|
|
@ -84,6 +83,7 @@ internal fun createRoomListRoomSummary(
|
|||
isFavorite: Boolean = false,
|
||||
displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM,
|
||||
heroes: List<AvatarData> = emptyList(),
|
||||
timestamp: String? = null,
|
||||
) = RoomListRoomSummary(
|
||||
id = A_ROOM_ID.value,
|
||||
roomId = A_ROOM_ID,
|
||||
|
|
@ -92,7 +92,7 @@ internal fun createRoomListRoomSummary(
|
|||
numberOfUnreadMessages = numberOfUnreadMessages,
|
||||
numberOfUnreadNotifications = numberOfUnreadNotifications,
|
||||
isMarkedUnread = isMarkedUnread,
|
||||
timestamp = A_FORMATTED_DATE,
|
||||
timestamp = timestamp,
|
||||
lastMessage = "",
|
||||
avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME, size = AvatarSize.RoomListItem),
|
||||
displayType = displayType,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import app.cash.molecule.moleculeFlow
|
|||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.roomlist.impl.datasource.aRoomListRoomSummaryFactory
|
||||
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
|
||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
|
|
@ -143,7 +143,7 @@ fun TestScope.createRoomListSearchPresenter(
|
|||
dataSource = RoomListSearchDataSource(
|
||||
roomListService = roomListService,
|
||||
roomSummaryFactory = aRoomListRoomSummaryFactory(
|
||||
lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(),
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
roomLastMessageFormatter = FakeRoomLastMessageFormatter(),
|
||||
),
|
||||
coroutineDispatchers = testCoroutineDispatchers(),
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<string name="screen_chat_backup_key_storage_toggle_title">"Salli avainten säilytys"</string>
|
||||
<string name="screen_chat_backup_recovery_action_change">"Vaihda palautusavain"</string>
|
||||
<string name="screen_chat_backup_recovery_action_change_description">"Palauta kryptografinen identiteettisi ja viestihistoriasi palautusavaimella, jos olet menettänyt kaikki nykyiset laitteesi."</string>
|
||||
<string name="screen_chat_backup_recovery_action_confirm">"Käytä palautusavainta"</string>
|
||||
<string name="screen_chat_backup_recovery_action_confirm">"Syötä palautusavain"</string>
|
||||
<string name="screen_chat_backup_recovery_action_confirm_description">"Avainten säilytys ei ole tällä hetkellä synkronoitu."</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup">"Ota palautus käyttöön"</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup_description">"Pääset käsiksi salattuihin viesteihisi, jos menetät kaikki laitteesi tai olet kirjautunut ulos %1$s -sovelluksesta kaikkialla."</string>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
<string name="screen_chat_backup_recovery_action_confirm">"Utiliser la clé de récupération"</string>
|
||||
<string name="screen_chat_backup_recovery_action_confirm_description">"Le stockage de vos clés est actuellement désynchronisé."</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup">"Configurer la sauvegarde"</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup_description">"Accédez à vos messages chiffrés si vous perdez tous vos appareils ou que vous êtes déconnectés de %1$s partout."</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup_description">"Accédez à vos messages chiffrés si vous perdez tous vos appareils ou que vous êtes déconnecté de %1$s partout."</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_1">"Ouvrez %1$s sur un ordinateur"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_2">"Connectez-vous à nouveau à votre compte"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_3">"Lorsque vous devrez vérifier la session, choisissez %1$s"</string>
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
<string name="screen_key_backup_disable_confirmation_title">"Êtes-vous certain de vouloir désactiver la sauvegarde ?"</string>
|
||||
<string name="screen_key_backup_disable_description">"Désactiver la sauvegarde supprimera votre clé de récupération actuelle et désactivera d’autres mesures de sécurité. Dans ce cas :"</string>
|
||||
<string name="screen_key_backup_disable_description_point_1">"Pas d’accès à l’historique des discussions chiffrées sur vos nouveaux appareils"</string>
|
||||
<string name="screen_key_backup_disable_description_point_2">"Perte de l’accès à vos messages chiffrés si vous êtes déconnectés de %1$s partout"</string>
|
||||
<string name="screen_key_backup_disable_description_point_2">"Perte de l’accès à vos messages chiffrés si vous êtes déconnecté de %1$s partout"</string>
|
||||
<string name="screen_key_backup_disable_title">"Êtes-vous certain de vouloir désactiver la sauvegarde ?"</string>
|
||||
<string name="screen_recovery_key_change_description">"Obtenez une nouvelle clé de récupération dans le cas où vous avez oublié l’ancienne. Après le changement, l’ancienne clé ne sera plus utilisable."</string>
|
||||
<string name="screen_recovery_key_change_generate_key">"Générer une nouvelle clé"</string>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ import dagger.assisted.AssistedFactory
|
|||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
|
|
@ -37,7 +38,7 @@ class IncomingVerificationPresenter @AssistedInject constructor(
|
|||
@Assisted private val navigator: IncomingVerificationNavigator,
|
||||
private val sessionVerificationService: SessionVerificationService,
|
||||
private val stateMachine: IncomingVerificationStateMachine,
|
||||
private val dateFormatter: LastMessageTimestampFormatter,
|
||||
private val dateFormatter: DateFormatter,
|
||||
) : Presenter<IncomingVerificationState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
|
@ -59,7 +60,10 @@ class IncomingVerificationPresenter @AssistedInject constructor(
|
|||
}
|
||||
val stateAndDispatch = stateMachine.rememberStateAndDispatch()
|
||||
val formattedSignInTime = remember {
|
||||
dateFormatter.format(sessionVerificationRequestDetails.firstSeenTimestamp)
|
||||
dateFormatter.format(
|
||||
timestamp = sessionVerificationRequestDetails.firstSeenTimestamp,
|
||||
mode = DateFormatterMode.TimeOrDate,
|
||||
)
|
||||
}
|
||||
val step by remember {
|
||||
derivedStateOf {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
<string name="screen_session_verification_compare_numbers_subtitle">"Varmista, että alla olevat numerot vastaavat toisessa istunnossa näkyviä numeroita."</string>
|
||||
<string name="screen_session_verification_compare_numbers_title">"Vertaa numeroita"</string>
|
||||
<string name="screen_session_verification_complete_subtitle">"Uusi kirjautumisesi on nyt vahvistettu. Sillä on pääsy salattuihin viesteihisi, ja muut käyttäjät näkevät sen luotettuna."</string>
|
||||
<string name="screen_session_verification_enter_recovery_key">"Käytä palautusavainta"</string>
|
||||
<string name="screen_session_verification_enter_recovery_key">"Syötä palautusavain"</string>
|
||||
<string name="screen_session_verification_failed_subtitle">"Joko pyyntö aikakatkaistiin, pyyntö hylättiin tai vahvistus ei täsmännyt."</string>
|
||||
<string name="screen_session_verification_open_existing_session_subtitle">"Vahvista, että se olet sinä, jotta näet aiemmat salatut viestisi."</string>
|
||||
<string name="screen_session_verification_open_existing_session_title">"Avaa laite, jossa olet jo kirjautuneena"</string>
|
||||
|
|
|
|||
|
|
@ -9,9 +9,8 @@ package io.element.android.features.verifysession.impl.incoming
|
|||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
|
||||
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
||||
import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE
|
||||
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.matrix.api.core.FlowId
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
|
|
@ -56,7 +55,7 @@ class IncomingVerificationPresenterTest {
|
|||
IncomingVerificationState.Step.Initial(
|
||||
deviceDisplayName = "a device name",
|
||||
deviceId = A_DEVICE_ID,
|
||||
formattedSignInTime = A_FORMATTED_DATE,
|
||||
formattedSignInTime = "567 TimeOrDate false",
|
||||
isWaiting = false,
|
||||
)
|
||||
)
|
||||
|
|
@ -119,7 +118,7 @@ class IncomingVerificationPresenterTest {
|
|||
IncomingVerificationState.Step.Initial(
|
||||
deviceDisplayName = "a device name",
|
||||
deviceId = A_DEVICE_ID,
|
||||
formattedSignInTime = A_FORMATTED_DATE,
|
||||
formattedSignInTime = "567 TimeOrDate false",
|
||||
isWaiting = false,
|
||||
)
|
||||
)
|
||||
|
|
@ -178,7 +177,7 @@ class IncomingVerificationPresenterTest {
|
|||
IncomingVerificationState.Step.Initial(
|
||||
deviceDisplayName = "a device name",
|
||||
deviceId = A_DEVICE_ID,
|
||||
formattedSignInTime = A_FORMATTED_DATE,
|
||||
formattedSignInTime = "567 TimeOrDate false",
|
||||
isWaiting = false,
|
||||
)
|
||||
)
|
||||
|
|
@ -210,7 +209,7 @@ class IncomingVerificationPresenterTest {
|
|||
IncomingVerificationState.Step.Initial(
|
||||
deviceDisplayName = "a device name",
|
||||
deviceId = A_DEVICE_ID,
|
||||
formattedSignInTime = A_FORMATTED_DATE,
|
||||
formattedSignInTime = "567 TimeOrDate false",
|
||||
isWaiting = false,
|
||||
)
|
||||
)
|
||||
|
|
@ -281,7 +280,7 @@ class IncomingVerificationPresenterTest {
|
|||
sessionVerificationRequestDetails: SessionVerificationRequestDetails = aSessionVerificationRequestDetails,
|
||||
navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() },
|
||||
service: SessionVerificationService = FakeSessionVerificationService(),
|
||||
dateFormatter: LastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(A_FORMATTED_DATE),
|
||||
dateFormatter: DateFormatter = FakeDateFormatter(),
|
||||
) = IncomingVerificationPresenter(
|
||||
sessionVerificationRequestDetails = sessionVerificationRequestDetails,
|
||||
navigator = navigator,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue