Merge branch 'develop' into feature/fga/knock_requests_sdk

This commit is contained in:
ganfra 2024-12-18 17:21:16 +01:00
commit 5275a3e5d3
391 changed files with 6199 additions and 1991 deletions

2
.idea/kotlinc.xml generated
View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.0.21" />
<option name="version" value="2.1.0" />
</component>
</project>

View file

@ -30,5 +30,6 @@ appId: ${MAESTRO_APP_ID}
# assert there's 1 member and 2 invitees
- tapOn: "Back"
- scroll
- scroll
- tapOn: "Leave room"
- tapOn: "Leave"

View file

@ -49,7 +49,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.4.19")
detektPlugins("io.nlopez.compose.rules:detekt:0.4.22")
}
tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {

View file

@ -130,9 +130,9 @@ Prerequisites:
```
cargo install cargo-ndk
```
* Install the Android Rust toolchain:
* Install the Android Rust toolchain for your machine's hardware:
```
rustup target add aarch64-linux-android
rustup target add aarch64-linux-android x86_64-linux-android
```
* Depending on the location of the Android SDK, you may need to set
`ANDROID_HOME`:

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 nont pas pu être acceptées. Voulez-vous réessayer ?"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_title">"Toutes les demandes nont 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 na pas pu être acceptée. Voulez-vous réessayer ?"</string>
<string name="screen_knock_requests_list_accept_failed_alert_title">"Impossible daccepter 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 laccè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 laccè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 navons 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 quelquun 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>

View file

@ -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>

View file

@ -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
}

View file

@ -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,
)
}
}

View file

@ -13,4 +13,6 @@ import kotlinx.collections.immutable.ImmutableList
data class DependencyLicensesListState(
val licenses: AsyncData<ImmutableList<DependencyLicenseItem>>,
val filter: String,
val eventSink: (DependencyLicensesListEvent) -> Unit,
)

View file

@ -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(

View file

@ -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)
}
)
}
}
}
}

View file

@ -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(

View file

@ -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>

View file

@ -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)

View file

@ -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,

View file

@ -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)

View file

@ -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)
}

View file

@ -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>,

View file

@ -37,6 +37,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
event = aTimelineItemEvent(
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",
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:00AM",
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:00AM",
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:00AM",
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:00AM",
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:00AM",
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:00AM",
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:00AM",
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:00AM",
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:00AM",
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:00AM",
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,

View file

@ -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,
)
}
}

View file

@ -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),
}

View file

@ -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,

View file

@ -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)
}
}
}

View file

@ -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
}

View file

@ -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(

View file

@ -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

View file

@ -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.

View file

@ -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()

View file

@ -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
)

View file

@ -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,

View file

@ -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(

View file

@ -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

View file

@ -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()
}
}

View file

@ -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))
}

View file

@ -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(),

View file

@ -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(),
)
}

View file

@ -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" })
}
}
}

View file

@ -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(),

View file

@ -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(

View file

@ -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

View file

@ -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
)
}

View file

@ -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 {

View file

@ -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)

View file

@ -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,

View file

@ -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,

View file

@ -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
)
}

View file

@ -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()
}
}
}

View file

@ -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>

View file

@ -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">"Ασφάλεια &amp; απόρρητο"</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>

View file

@ -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>

View file

@ -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é &amp; 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>

View file

@ -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>

View file

@ -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 &amp; 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>

View file

@ -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 = {},
)
}

View file

@ -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
}

View file

@ -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.
*/

View file

@ -46,6 +46,7 @@ data class RoomListState(
val isDm: Boolean,
val isFavorite: Boolean,
val markAsUnreadFeatureFlagEnabled: Boolean,
val eventCacheFeatureFlagEnabled: Boolean,
val hasNewContent: Boolean,
) : ContextMenu
}

View file

@ -31,4 +31,5 @@ internal fun aContextMenuShown(
markAsUnreadFeatureFlagEnabled = true,
hasNewContent = hasNewContent,
isFavorite = isFavorite,
eventCacheFeatureFlagEnabled = false,
)

View file

@ -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(),

View file

@ -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(),

View file

@ -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

View file

@ -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
)

View file

@ -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,

View file

@ -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(),

View file

@ -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>

View file

@ -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 dautres mesures de sécurité. Dans ce cas :"</string>
<string name="screen_key_backup_disable_description_point_1">"Pas daccès à lhistorique des discussions chiffrées sur vos nouveaux appareils"</string>
<string name="screen_key_backup_disable_description_point_2">"Perte de laccè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 laccè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é lancienne. Après le changement, lancienne clé ne sera plus utilisable."</string>
<string name="screen_recovery_key_change_generate_key">"Générer une nouvelle clé"</string>

View file

@ -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 {

View file

@ -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>

View file

@ -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,

View file

@ -3,10 +3,10 @@
[versions]
# Project
android_gradle_plugin = "8.7.2"
kotlin = "2.0.21"
android_gradle_plugin = "8.7.3"
kotlin = "2.1.0"
kotlinpoet = "2.0.0"
ksp = "2.0.21-1.0.28"
ksp = "2.1.0-1.0.29"
firebaseAppDistribution = "5.0.0"
# AndroidX
@ -22,17 +22,17 @@ constraintlayout_compose = "1.1.0"
lifecycle = "2.8.7"
activity = "1.9.3"
media3 = "1.5.0"
camera = "1.4.0"
camera = "1.4.1"
# Compose
compose_bom = "2024.11.00"
compose_bom = "2024.12.01"
composecompiler = "1.5.15"
# Coroutines
coroutines = "1.9.0"
# Accompanist
accompanist = "0.36.0"
accompanist = "0.37.0"
# Test
test_core = "1.6.1"
@ -50,10 +50,10 @@ wysiwyg = "2.37.14"
telephoto = "0.14.0"
# Dependency analysis
dependencyAnalysis = "2.5.0"
dependencyAnalysis = "2.6.1"
# DI
dagger = "2.53"
dagger = "2.53.1"
anvil = "0.4.0"
# Auto service
@ -61,7 +61,7 @@ autoservice = "1.1.1"
# quality
androidx-test-ext-junit = "1.2.1"
kover = "0.8.3"
kover = "0.9.0"
[libraries]
# Project
@ -150,7 +150,7 @@ test_arch_core = "androidx.arch.core:core-testing:2.2.0"
test_junit = "junit:junit:4.13.2"
test_runner = "androidx.test:runner:1.6.2"
test_mockk = "io.mockk:mockk:1.13.13"
test_konsist = "com.lemonappdev:konsist:0.17.1"
test_konsist = "com.lemonappdev:konsist:0.17.3"
test_turbine = "app.cash.turbine:turbine:1.2.0"
test_truth = "com.google.truth:truth:1.4.4"
test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.18"
@ -169,11 +169,11 @@ serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-jso
kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8"
showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" }
showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" }
jsoup = "org.jsoup:jsoup:1.18.1"
jsoup = "org.jsoup:jsoup:1.18.3"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0"
timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.70"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.72"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@ -187,7 +187,7 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.2.2"
maplibre = "org.maplibre.gl:android-sdk:11.6.1"
maplibre = "org.maplibre.gl:android-sdk:11.7.0"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
opusencoder = "io.element.android:opusencoder:1.1.0"
@ -195,7 +195,7 @@ zxing_cpp = "io.github.zxing-cpp:android:2.2.0"
# Analytics
posthog = "com.posthog:posthog-android:3.9.3"
sentry = "io.sentry:sentry-android:7.18.1"
sentry = "io.sentry:sentry-android:7.19.0"
# main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0"

View file

@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=31c55713e40233a8303827ceb42ca48a47267a0ad4bab9177123121e71524c26
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
distributionSha256Sum=f397b287023acdba1e9f6fc5ea72d22dd63669d59ed4a289a29b1a76eee151c6
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View file

@ -10,10 +10,13 @@ package io.element.android.libraries.androidutils.browser
import android.app.Activity
import android.content.ActivityNotFoundException
import android.net.Uri
import android.os.Bundle
import android.provider.Browser
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.browser.customtabs.CustomTabsSession
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import java.util.Locale
/**
* Open url in custom tab or, if not available, in the default browser.
@ -51,6 +54,9 @@ fun Activity.openUrlInChromeCustomTab(
intent.putExtra("org.chromium.chrome.browser.customtabs.EXTRA_DISABLE_DOWNLOAD_BUTTON", true)
// Disable bookmark button
intent.putExtra("org.chromium.chrome.browser.customtabs.EXTRA_DISABLE_START_BUTTON", true)
intent.putExtra(Browser.EXTRA_HEADERS, Bundle().apply {
putString("Accept-Language", Locale.getDefault().toLanguageTag())
})
}
.launchUrl(this, Uri.parse(url))
} catch (activityNotFoundException: ActivityNotFoundException) {

View file

@ -7,6 +7,9 @@
package io.element.android.libraries.core.extensions
import java.text.Normalizer
import java.util.Locale
fun Boolean.toOnOff() = if (this) "ON" else "OFF"
fun Boolean.to01() = if (this) "1" else "0"
@ -68,3 +71,21 @@ fun String.replacePrefix(oldPrefix: String, newPrefix: String): String {
fun String.withBrackets(prefix: String = "(", suffix: String = ")"): String {
return "$prefix$this$suffix"
}
/**
* Capitalize the string.
*/
fun String.safeCapitalize(): String {
return replaceFirstChar {
if (it.isLowerCase()) {
it.titlecase(Locale.getDefault())
} else {
it.toString()
}
}
}
fun String.withoutAccents(): String {
return Normalizer.normalize(this, Normalizer.Form.NFD)
.replace("\\p{Mn}+".toRegex(), "")
}

View file

@ -0,0 +1,56 @@
/*
* 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.libraries.dateformatter.api
interface DateFormatter {
fun format(
timestamp: Long?,
mode: DateFormatterMode = DateFormatterMode.Full,
useRelative: Boolean = false,
): String
}
enum class DateFormatterMode {
/**
* Full date and time.
* Example:
* "April 6, 1980 at 6:35 PM"
* Format can be shorter when useRelative is true.
* Example:
* "6:35 PM"
*/
Full,
/**
* Only month and year.
* Example:
* "April 1980"
* "This month" can be returned when useRelative is true.
* Example:
* "This month"
*/
Month,
/**
* Only day.
* Example:
* "Sunday 6 April"
* "Today", "Yesterday" and day of week can be returned when useRelative is true.
*/
Day,
/**
* Time if same day, else date.
*/
TimeOrDate,
/**
* Only time whatever the day.
*/
TimeOnly,
}

View file

@ -1,12 +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.libraries.dateformatter.api
interface DaySeparatorFormatter {
fun format(timestamp: Long): String
}

View file

@ -1,12 +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.libraries.dateformatter.api
fun interface LastMessageTimestampFormatter {
fun format(timestamp: Long?): String
}

View file

@ -8,7 +8,7 @@ import extension.setupAnvil
*/
plugins {
id("io.element.android-library")
id("io.element.android-compose-library")
}
setupAnvil()
@ -16,15 +16,30 @@ setupAnvil()
android {
namespace = "io.element.android.libraries.dateformatter.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
dependencies {
implementation(libs.dagger)
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.di)
implementation(projects.libraries.uiStrings)
implementation(projects.services.toolbox.api)
api(projects.libraries.dateformatter.api)
api(libs.datetime)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
}
}

View file

@ -0,0 +1,57 @@
/*
* 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.libraries.dateformatter.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.extensions.safeCapitalize
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
interface DateFormatterDay {
fun format(
timestamp: Long,
useRelative: Boolean,
): String
}
@ContributesBinding(AppScope::class)
class DefaultDateFormatterDay @Inject constructor(
private val localDateTimeProvider: LocalDateTimeProvider,
private val dateFormatters: DateFormatters,
) : DateFormatterDay {
override fun format(
timestamp: Long,
useRelative: Boolean,
): String {
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
val today = localDateTimeProvider.providesNow()
return if (useRelative) {
val dayDiff = today.date.toEpochDays() - dateToFormat.date.toEpochDays()
when (dayDiff) {
0 -> dateFormatters.getRelativeDay(timestamp, "Today")
1 -> dateFormatters.getRelativeDay(timestamp, "Yesterday")
else -> if (dayDiff < 7) {
dateFormatters.formatDateWithDay(dateToFormat)
} else {
if (today.year == dateToFormat.year) {
dateFormatters.formatDateWithFullFormatNoYear(dateToFormat)
} else {
dateFormatters.formatDateWithFullFormat(dateToFormat)
}
}
}
} else {
if (today.year == dateToFormat.year) {
dateFormatters.formatDateWithFullFormatNoYear(dateToFormat)
} else {
dateFormatters.formatDateWithFullFormat(dateToFormat)
}
}
.safeCapitalize()
}
}

View file

@ -0,0 +1,38 @@
/*
* 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.libraries.dateformatter.impl
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
class DateFormatterFull @Inject constructor(
private val stringProvider: StringProvider,
private val localDateTimeProvider: LocalDateTimeProvider,
private val dateFormatters: DateFormatters,
private val dateFormatterDay: DateFormatterDay,
) {
fun format(
timestamp: Long,
useRelative: Boolean,
): String {
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
val time = dateFormatters.formatTime(dateToFormat)
return if (useRelative) {
val now = localDateTimeProvider.providesNow()
if (now.date == dateToFormat.date) {
time
} else {
val dateStr = dateFormatterDay.format(timestamp, true)
stringProvider.getString(R.string.common_date_date_at_time, dateStr, time)
}
} else {
val dateStr = dateFormatters.formatDateWithFullFormat(dateToFormat)
stringProvider.getString(R.string.common_date_date_at_time, dateStr, time)
}
}
}

View file

@ -0,0 +1,32 @@
/*
* 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.libraries.dateformatter.impl
import io.element.android.libraries.core.extensions.safeCapitalize
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
class DateFormatterMonth @Inject constructor(
private val stringProvider: StringProvider,
private val localDateTimeProvider: LocalDateTimeProvider,
private val dateFormatters: DateFormatters,
) {
fun format(
timestamp: Long,
useRelative: Boolean,
): String {
val today = localDateTimeProvider.providesNow()
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
return if (useRelative && dateToFormat.month == today.month && dateToFormat.year == today.year) {
stringProvider.getString(R.string.common_date_this_month)
} else {
dateFormatters.formatDateWithMonthAndYear(dateToFormat)
}
.safeCapitalize()
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
@ -7,18 +7,16 @@
package io.element.android.libraries.dateformatter.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultLastMessageTimestampFormatter @Inject constructor(
class DateFormatterTime @Inject constructor(
private val localDateTimeProvider: LocalDateTimeProvider,
private val dateFormatters: DateFormatters,
) : LastMessageTimestampFormatter {
override fun format(timestamp: Long?): String {
if (timestamp == null) return ""
) {
fun format(
timestamp: Long,
useRelative: Boolean,
): String {
val currentDate = localDateTimeProvider.providesNow()
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
val isSameDay = currentDate.date == dateToFormat.date
@ -30,7 +28,7 @@ class DefaultLastMessageTimestampFormatter @Inject constructor(
dateFormatters.formatDate(
dateToFormat = dateToFormat,
currentDate = currentDate,
useRelative = true
useRelative = useRelative,
)
}
}

View file

@ -0,0 +1,22 @@
/*
* 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.libraries.dateformatter.impl
import javax.inject.Inject
class DateFormatterTimeOnly @Inject constructor(
private val localDateTimeProvider: LocalDateTimeProvider,
private val dateFormatters: DateFormatters,
) {
fun format(
timestamp: Long,
): String {
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
return dateFormatters.formatTime(dateToFormat)
}
}

View file

@ -7,57 +7,64 @@
package io.element.android.libraries.dateformatter.impl
import android.text.format.DateFormat
import android.text.format.DateUtils
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.toInstant
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime
import timber.log.Timber
import java.time.Period
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
import javax.inject.Inject
import kotlin.math.absoluteValue
@SingleIn(AppScope::class)
class DateFormatters @Inject constructor(
private val locale: Locale,
localeChangeObserver: LocaleChangeObserver,
private val clock: Clock,
private val timeZoneProvider: TimezoneProvider,
) {
private val onlyTimeFormatter: DateTimeFormatter by lazy {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
locale: Locale,
) : LocaleChangeListener {
init {
localeChangeObserver.addListener(this)
}
private val dateWithMonthFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "d MMM") ?: "d MMM"
DateTimeFormatter.ofPattern(pattern, locale)
}
private var dateTimeFormatters: DateTimeFormatters = DateTimeFormatters(locale)
private val dateWithYearFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "dd.MM.yyyy") ?: "dd.MM.yyyy"
DateTimeFormatter.ofPattern(pattern, locale)
}
private val dateWithFullFormatFormatter: DateTimeFormatter by lazy {
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
override fun onLocaleChange() {
Timber.w("Locale changed, updating formatters")
dateTimeFormatters = DateTimeFormatters(Locale.getDefault())
}
internal fun formatTime(localDateTime: LocalDateTime): String {
return onlyTimeFormatter.format(localDateTime.toJavaLocalDateTime())
return dateTimeFormatters.onlyTimeFormatter.format(localDateTime.toJavaLocalDateTime())
}
internal fun formatDateWithMonthAndYear(localDateTime: LocalDateTime): String {
return dateTimeFormatters.dateWithMonthAndYearFormatter.format(localDateTime.toJavaLocalDateTime())
}
internal fun formatDateWithMonth(localDateTime: LocalDateTime): String {
return dateWithMonthFormatter.format(localDateTime.toJavaLocalDateTime())
return dateTimeFormatters.dateWithMonthFormatter.format(localDateTime.toJavaLocalDateTime())
}
internal fun formatDateWithDay(localDateTime: LocalDateTime): String {
return dateTimeFormatters.dateWithDayFormatter.format(localDateTime.toJavaLocalDateTime())
}
internal fun formatDateWithYear(localDateTime: LocalDateTime): String {
return dateWithYearFormatter.format(localDateTime.toJavaLocalDateTime())
return dateTimeFormatters.dateWithYearFormatter.format(localDateTime.toJavaLocalDateTime())
}
internal fun formatDateWithFullFormat(localDateTime: LocalDateTime): String {
return dateWithFullFormatFormatter.format(localDateTime.toJavaLocalDateTime())
return dateTimeFormatters.dateWithFullFormatFormatter.format(localDateTime.toJavaLocalDateTime())
}
internal fun formatDateWithFullFormatNoYear(localDateTime: LocalDateTime): String {
return dateTimeFormatters.dateWithFullFormatNoYearFormatter.format(localDateTime.toJavaLocalDateTime())
}
internal fun formatDate(
@ -75,12 +82,12 @@ class DateFormatters @Inject constructor(
}
}
private fun getRelativeDay(ts: Long): String {
internal fun getRelativeDay(ts: Long, default: String = ""): String {
return DateUtils.getRelativeTimeSpanString(
ts,
clock.now().toEpochMilliseconds(),
DateUtils.DAY_IN_MILLIS,
DateUtils.FORMAT_SHOW_WEEKDAY
)?.toString() ?: ""
)?.toString() ?: default
}
}

View file

@ -0,0 +1,54 @@
/*
* 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.libraries.dateformatter.impl
import android.text.format.DateFormat
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
class DateTimeFormatters(
private val locale: Locale,
) {
val onlyTimeFormatter: DateTimeFormatter by lazy {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
}
val dateWithMonthAndYearFormatter: DateTimeFormatter by lazy {
val pattern = bestDateTimePattern("MMMM YYYY")
DateTimeFormatter.ofPattern(pattern, locale)
}
val dateWithMonthFormatter: DateTimeFormatter by lazy {
val pattern = bestDateTimePattern("d MMM")
DateTimeFormatter.ofPattern(pattern, locale)
}
val dateWithDayFormatter: DateTimeFormatter by lazy {
val pattern = bestDateTimePattern("EEEE")
DateTimeFormatter.ofPattern(pattern, locale)
}
val dateWithYearFormatter: DateTimeFormatter by lazy {
val pattern = bestDateTimePattern("dd.MM.yyyy")
DateTimeFormatter.ofPattern(pattern, locale)
}
val dateWithFullFormatFormatter: DateTimeFormatter by lazy {
DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(locale)
}
val dateWithFullFormatNoYearFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "EEEE d MMMM") ?: "EEEE d MMMM"
DateTimeFormatter.ofPattern(pattern, locale)
}
private fun bestDateTimePattern(pattern: String): String {
return DateFormat.getBestDateTimePattern(locale, pattern) ?: pattern
}
}

View file

@ -0,0 +1,48 @@
/*
* 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.libraries.dateformatter.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultDateFormatter @Inject constructor(
private val dateFormatterFull: DateFormatterFull,
private val dateFormatterMonth: DateFormatterMonth,
private val dateFormatterDay: DateFormatterDay,
private val dateFormatterTime: DateFormatterTime,
private val dateFormatterTimeOnly: DateFormatterTimeOnly,
) : DateFormatter {
override fun format(
timestamp: Long?,
mode: DateFormatterMode,
useRelative: Boolean,
): String {
timestamp ?: return ""
return when (mode) {
DateFormatterMode.Full -> {
dateFormatterFull.format(timestamp, useRelative)
}
DateFormatterMode.Month -> {
dateFormatterMonth.format(timestamp, useRelative)
}
DateFormatterMode.Day -> {
dateFormatterDay.format(timestamp, useRelative)
}
DateFormatterMode.TimeOrDate -> {
dateFormatterTime.format(timestamp, useRelative)
}
DateFormatterMode.TimeOnly -> {
dateFormatterTimeOnly.format(timestamp)
}
}
}
}

View file

@ -1,25 +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.libraries.dateformatter.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.dateformatter.api.DaySeparatorFormatter
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultDaySeparatorFormatter @Inject constructor(
private val localDateTimeProvider: LocalDateTimeProvider,
private val dateFormatters: DateFormatters,
) : DaySeparatorFormatter {
override fun format(timestamp: Long): String {
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
// TODO use relative formatting once iOS uses it too
return dateFormatters.formatDateWithFullFormat(dateToFormat)
}
}

View file

@ -0,0 +1,56 @@
/*
* 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.libraries.dateformatter.impl
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import javax.inject.Inject
fun interface LocaleChangeObserver {
fun addListener(listener: LocaleChangeListener)
}
interface LocaleChangeListener {
fun onLocaleChange()
}
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultLocaleChangeObserver @Inject constructor(
@ApplicationContext private val context: Context,
) : LocaleChangeObserver {
init {
registerReceiver(object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
listeners.forEach(LocaleChangeListener::onLocaleChange)
}
})
}
private val listeners = mutableSetOf<LocaleChangeListener>()
override fun addListener(listener: LocaleChangeListener) {
listeners.add(listener)
}
private fun registerReceiver(receiver: BroadcastReceiver) {
val filter = IntentFilter()
filter.addAction(Intent.ACTION_LOCALE_CHANGED)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
filter.addAction(Intent.ACTION_APPLICATION_LOCALE_CHANGED)
}
context.registerReceiver(receiver, filter)
}
}

View file

@ -0,0 +1,53 @@
/*
* 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.libraries.dateformatter.impl.previews
data class DateForPreview(
val semantic: String,
val date: String,
)
val dateForPreviewToday = DateForPreview(
semantic = "Today",
date = "1980-04-06T18:35:24.00Z",
)
val dateForPreviews = listOf(
DateForPreview(
semantic = "Now",
date = dateForPreviewToday.date,
),
DateForPreview(
semantic = "One second ago",
date = "1980-04-06T18:35:23.00Z",
),
DateForPreview(
semantic = "One minute ago",
date = "1980-04-06T18:34:24.00Z",
),
DateForPreview(
semantic = "One hour ago",
date = "1980-04-06T17:35:24.00Z",
),
DateForPreview(
semantic = "One day ago",
date = "1980-04-05T18:35:24.00Z",
),
DateForPreview(
semantic = "Two days ago",
date = "1980-04-04T18:35:24.00Z",
),
DateForPreview(
semantic = "One month ago",
date = "1980-03-06T18:35:24.00Z",
),
DateForPreview(
semantic = "One year ago",
date = "1979-04-06T18:35:24.00Z",
),
)

View file

@ -0,0 +1,16 @@
/*
* 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.libraries.dateformatter.impl.previews
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.dateformatter.api.DateFormatterMode
class DateFormatterModeProvider : PreviewParameterProvider<DateFormatterMode> {
override val values: Sequence<DateFormatterMode>
get() = DateFormatterMode.entries.asSequence()
}

View file

@ -0,0 +1,124 @@
/*
* 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.libraries.dateformatter.impl.previews
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.dateformatter.impl.DefaultDateFormatter
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.allBooleans
import kotlinx.datetime.Instant
@Preview
@Composable
internal fun DateFormatterModeViewPreview(
@PreviewParameter(DateFormatterModeProvider::class) dateFormatterMode: DateFormatterMode,
) = ElementPreview {
DateFormatterModeView(dateFormatterMode)
}
@Composable
private fun DateFormatterModeView(
mode: DateFormatterMode,
) {
val context = LocalContext.current
val composeLocale = Locale.current
val dateFormatter = remember {
createFormatter(
context = context,
currentDate = dateForPreviewToday.date,
locale = java.util.Locale.Builder()
.setLanguageTag(composeLocale.toLanguageTag())
.build(),
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Mode $mode / $composeLocale",
style = ElementTheme.typography.fontHeadingSmMedium
)
val today = Instant.parse(dateForPreviewToday.date).toEpochMilliseconds()
Text(
text = "Today is: ${dateFormatter.format(today, DateFormatterMode.Full, useRelative = false)}",
style = ElementTheme.typography.fontHeadingSmMedium,
)
dateForPreviews.forEach { dateForPreview ->
DateForPreviewItem(
dateForPreview = dateForPreview,
dateFormatter = dateFormatter,
mode = mode,
)
}
}
}
@Composable
private fun DateForPreviewItem(
dateForPreview: DateForPreview,
dateFormatter: DefaultDateFormatter,
mode: DateFormatterMode,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(2.dp),
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp),
text = dateForPreview.semantic,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textSecondary,
)
val ts = Instant.parse(dateForPreview.date).toEpochMilliseconds()
Row {
Column {
listOf("Absolute:", "Relative:").forEach { label ->
Text(
text = label,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
Spacer(modifier = Modifier.width(8.dp))
Column {
allBooleans.forEach { useRelative ->
Text(
modifier = Modifier.fillMaxWidth(),
text = dateFormatter.format(ts, mode, useRelative),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
}
}
}

View file

@ -0,0 +1,66 @@
/*
* 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.libraries.dateformatter.impl.previews
import android.content.Context
import io.element.android.libraries.dateformatter.impl.DateFormatterFull
import io.element.android.libraries.dateformatter.impl.DateFormatterMonth
import io.element.android.libraries.dateformatter.impl.DateFormatterTime
import io.element.android.libraries.dateformatter.impl.DateFormatterTimeOnly
import io.element.android.libraries.dateformatter.impl.DateFormatters
import io.element.android.libraries.dateformatter.impl.DefaultDateFormatter
import io.element.android.libraries.dateformatter.impl.DefaultDateFormatterDay
import io.element.android.libraries.dateformatter.impl.LocalDateTimeProvider
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import java.util.Locale
/**
* Create DefaultDateFormatter and set current time to the provided date.
*/
fun createFormatter(
context: Context,
currentDate: String,
locale: Locale,
): DefaultDateFormatter {
val clock = PreviewClock().apply { givenInstant(Instant.parse(currentDate)) }
val localDateTimeProvider = LocalDateTimeProvider(clock) { TimeZone.UTC }
val dateFormatters = DateFormatters(
localeChangeObserver = {},
clock = clock,
timeZoneProvider = { TimeZone.UTC },
locale = locale,
)
val stringProvider = PreviewStringProvider(context.resources)
val dateFormatterDay = DefaultDateFormatterDay(
localDateTimeProvider = localDateTimeProvider,
dateFormatters = dateFormatters,
)
return DefaultDateFormatter(
dateFormatterFull = DateFormatterFull(
stringProvider = stringProvider,
localDateTimeProvider = localDateTimeProvider,
dateFormatters = dateFormatters,
dateFormatterDay = dateFormatterDay,
),
dateFormatterMonth = DateFormatterMonth(
stringProvider = stringProvider,
localDateTimeProvider = localDateTimeProvider,
dateFormatters = dateFormatters,
),
dateFormatterDay = dateFormatterDay,
dateFormatterTime = DateFormatterTime(
localDateTimeProvider = localDateTimeProvider,
dateFormatters = dateFormatters,
),
dateFormatterTimeOnly = DateFormatterTimeOnly(
localDateTimeProvider = localDateTimeProvider,
dateFormatters = dateFormatters,
),
)
}

View file

@ -0,0 +1,21 @@
/*
* 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.libraries.dateformatter.impl.previews
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
class PreviewClock : Clock {
private var instant: Instant = Instant.fromEpochMilliseconds(0)
fun givenInstant(instant: Instant) {
this.instant = instant
}
override fun now(): Instant = instant
}

View file

@ -0,0 +1,29 @@
/*
* 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.libraries.dateformatter.impl.previews
import android.content.res.Resources
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import io.element.android.services.toolbox.api.strings.StringProvider
class PreviewStringProvider(
private val resources: Resources
) : StringProvider {
override fun getString(@StringRes resId: Int): String {
return resources.getString(resId)
}
override fun getString(@StringRes resId: Int, vararg formatArgs: Any?): String {
return resources.getString(resId, *formatArgs)
}
override fun getQuantityString(@PluralsRes resId: Int, quantity: Int, vararg formatArgs: Any?): String {
return resources.getQuantityString(resId, quantity, *formatArgs)
}
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="common_date_date_at_time">"%1$s v %2$s"</string>
<string name="common_date_this_month">"Tento měsíc"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="common_date_this_month">"Αυτό το μήνα"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="common_date_this_month">"Sel kuul"</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="common_date_date_at_time">"%1$s à %2$s"</string>
<string name="common_date_this_month">"Ce mois-ci"</string>
</resources>

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