Merge branch 'develop' into feature/fga/space_members_access

This commit is contained in:
ganfra 2026-01-08 13:46:02 +01:00
commit 0668135d0e
215 changed files with 2349 additions and 1664 deletions

View file

@ -28,8 +28,6 @@ import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncService
@ -48,7 +46,6 @@ class HomePresenter(
private val homeSpacesPresenter: Presenter<HomeSpacesState>,
private val logoutPresenter: Presenter<DirectLogoutState>,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val featureFlagService: FeatureFlagService,
private val sessionStore: SessionStore,
private val announcementService: AnnouncementService,
) : Presenter<HomeState> {
@ -69,9 +66,6 @@ class HomePresenter(
val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
val roomListState = roomListPresenter.present()
val homeSpacesState = homeSpacesPresenter.present()
val isSpaceFeatureEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Space)
}.collectAsState(initial = false)
var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) }
val currentHomeNavigationBarItem by remember {
derivedStateOf {
@ -117,7 +111,6 @@ class HomePresenter(
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
directLogoutState = directLogoutState,
isSpaceFeatureEnabled = isSpaceFeatureEnabled,
eventSink = ::handleEvent,
)
}

View file

@ -29,10 +29,9 @@ data class HomeState(
val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean,
val directLogoutState: DirectLogoutState,
val isSpaceFeatureEnabled: Boolean,
val eventSink: (HomeEvents) -> Unit,
) {
val displayActions = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats
val displayRoomListFilters = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats && roomListState.displayFilters
val showNavigationBar = isSpaceFeatureEnabled && homeSpacesState.spaceRooms.isNotEmpty()
val showNavigationBar = homeSpacesState.spaceRooms.isNotEmpty()
}

View file

@ -31,7 +31,6 @@ open class HomeStateProvider : PreviewParameterProvider<HomeState> {
aHomeState(hasNetworkConnection = false),
aHomeState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)),
aHomeState(
isSpaceFeatureEnabled = true,
roomListState = aRoomListState(
// Add more rooms to see the blur effect under the NavigationBar
contentState = aRoomsContentState(
@ -42,7 +41,6 @@ open class HomeStateProvider : PreviewParameterProvider<HomeState> {
homeSpacesState = aHomeSpacesState(),
),
aHomeState(
isSpaceFeatureEnabled = true,
currentHomeNavigationBarItem = HomeNavigationBarItem.Spaces,
),
) + RoomListStateProvider().values.map {
@ -60,7 +58,6 @@ internal fun aHomeState(
roomListState: RoomListState = aRoomListState(),
homeSpacesState: HomeSpacesState = aHomeSpacesState(),
canReportBug: Boolean = true,
isSpaceFeatureEnabled: Boolean = false,
directLogoutState: DirectLogoutState = aDirectLogoutState(),
eventSink: (HomeEvents) -> Unit = {}
) = HomeState(
@ -73,6 +70,5 @@ internal fun aHomeState(
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState,
homeSpacesState = homeSpacesState,
isSpaceFeatureEnabled = isSpaceFeatureEnabled,
eventSink = eventSink,
)

View file

@ -179,14 +179,10 @@ private fun HomeScaffold(
displayFilters = state.displayRoomListFilters,
filtersState = roomListState.filtersState,
canReportBug = state.canReportBug,
modifier = if (state.isSpaceFeatureEnabled) {
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick(),
)
} else {
Modifier.background(ElementTheme.colors.bgCanvasDefault)
}
modifier = Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick(),
)
)
},
bottomBar = {

View file

@ -10,6 +10,5 @@ package io.element.android.features.home.impl.search
sealed interface RoomListSearchEvents {
data object ToggleSearchVisibility : RoomListSearchEvents
data class QueryChanged(val query: String) : RoomListSearchEvents
data object ClearQuery : RoomListSearchEvents
}

View file

@ -8,6 +8,8 @@
package io.element.android.features.home.impl.search
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -29,29 +31,24 @@ class RoomListSearchPresenter(
var isSearchActive by remember {
mutableStateOf(false)
}
var searchQuery by remember {
mutableStateOf("")
}
val searchQuery = rememberTextFieldState()
LaunchedEffect(isSearchActive) {
dataSource.setIsActive(isSearchActive)
}
LaunchedEffect(searchQuery) {
dataSource.setSearchQuery(searchQuery)
LaunchedEffect(searchQuery.text) {
dataSource.setSearchQuery(searchQuery.text.toString())
}
fun handleEvent(event: RoomListSearchEvents) {
when (event) {
RoomListSearchEvents.ClearQuery -> {
searchQuery = ""
}
is RoomListSearchEvents.QueryChanged -> {
searchQuery = event.query
searchQuery.clearText()
}
RoomListSearchEvents.ToggleSearchVisibility -> {
isSearchActive = !isSearchActive
searchQuery = ""
searchQuery.clearText()
}
}
}

View file

@ -8,12 +8,13 @@
package io.element.android.features.home.impl.search
import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.features.home.impl.model.RoomListRoomSummary
import kotlinx.collections.immutable.ImmutableList
data class RoomListSearchState(
val isSearchActive: Boolean,
val query: String,
val query: TextFieldState,
val results: ImmutableList<RoomListRoomSummary>,
val eventSink: (RoomListSearchEvents) -> Unit
)

View file

@ -8,6 +8,7 @@
package io.element.android.features.home.impl.search
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.roomlist.aRoomListRoomSummaryList
@ -33,7 +34,7 @@ fun aRoomListSearchState(
eventSink: (RoomListSearchEvents) -> Unit = { },
) = RoomListSearchState(
isSearchActive = isSearchActive,
query = query,
query = TextFieldState(initialText = query),
results = results,
eventSink = eventSink,
)

View file

@ -18,16 +18,13 @@ 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.foundation.text.input.TextFieldLineLimits
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
@ -35,7 +32,6 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
@ -112,23 +108,14 @@ private fun RoomListSearchContent(
},
navigationIcon = { BackButton(onClick = ::onBackButtonClick) },
title = {
// TODO replace `state.query` with TextFieldState when it's available for M3 TextField
// The stateSaver will keep the selection state when returning to this UI
var value by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(state.query))
}
val focusRequester = remember { FocusRequester() }
FilledTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
value = value,
singleLine = true,
onValueChange = {
value = it
state.eventSink(RoomListSearchEvents.QueryChanged(it.text))
},
state = state.query,
lineLimits = TextFieldLineLimits.SingleLine,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
@ -138,20 +125,18 @@ private fun RoomListSearchContent(
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
),
trailingIcon = {
if (value.text.isNotEmpty()) {
IconButton(onClick = {
state.eventSink(RoomListSearchEvents.ClearQuery)
// Clear local state too
value = value.copy(text = "")
}) {
trailingIcon = if (state.query.text.isNotEmpty()) {
@Composable {
IconButton(onClick = { state.eventSink(RoomListSearchEvents.ClearQuery) }) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_cancel)
)
}
}
}
} else {
null
},
)
LaunchedEffect(Unit) {

View file

@ -30,13 +30,13 @@
<string name="screen_roomlist_empty_title">"Još nema razgovora."</string>
<string name="screen_roomlist_filter_favourites">"Favoriti"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Razgovor možete dodati u favorite u postavkama razgovora.
Zasad možete poništiti odabir filtera kako biste vidjeli ostale razgovore."</string>
Zasad možete poništiti odabir filtara kako biste vidjeli ostale razgovore."</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Još nemate omiljenih razgovora"</string>
<string name="screen_roomlist_filter_invites">"Pozivnice"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Nemate pozivnica na čekanju."</string>
<string name="screen_roomlist_filter_low_priority">"Nizak prioritet"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Još nemate razgovora niskog prioriteta"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Možete poništiti odabir filtera kako biste vidjeli ostale razgovore"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Možete poništiti odabir filtara kako biste vidjeli ostale razgovore"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Nemate razgovora za ovaj odabir"</string>
<string name="screen_roomlist_filter_people">"Osobe"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Nemate još nijednu izravnu poruku"</string>

View file

@ -22,9 +22,6 @@ import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.features.rageshake.test.logs.FakeAnnouncementService
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.indicator.test.FakeIndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
@ -35,7 +32,6 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
@ -54,8 +50,6 @@ class HomePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
private val isSpaceEnabled = FeatureFlags.Space.defaultValue(aBuildMeta())
@Test
fun `present - should start with no user and then load user with success`() = runTest {
val matrixClient = FakeMatrixClient(
@ -79,7 +73,6 @@ class HomePresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
if (isSpaceEnabled) skipItems(1)
val initialState = awaitItem()
assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(
MatrixUser(A_USER_ID, null, null)
@ -91,8 +84,7 @@ class HomePresenterTest {
MatrixUser(A_USER_ID, A_USER_NAME, AN_AVATAR_URL)
)
assertThat(withUserState.showAvatarIndicator).isFalse()
assertThat(withUserState.isSpaceFeatureEnabled).isEqualTo(isSpaceEnabled)
assertThat(withUserState.showNavigationBar).isEqualTo(isSpaceEnabled)
assertThat(withUserState.showNavigationBar).isTrue()
}
}
@ -114,23 +106,6 @@ class HomePresenterTest {
}
}
@Test
fun `present - space feature enabled`() = runTest {
val presenter = createHomePresenter(
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.Space.key to true),
),
sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
),
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isSpaceFeatureEnabled).isTrue()
}
}
@Test
fun `present - show avatar indicator`() = runTest {
val indicatorService = FakeIndicatorService()
@ -143,7 +118,6 @@ class HomePresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
if (isSpaceEnabled) skipItems(1)
val initialState = awaitItem()
assertThat(initialState.showAvatarIndicator).isFalse()
indicatorService.setShowRoomListTopBarIndicator(true)
@ -168,7 +142,6 @@ class HomePresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
if (isSpaceEnabled) skipItems(1)
val initialState = awaitItem()
assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(MatrixUser(matrixClient.sessionId))
// No new state is coming
@ -189,7 +162,6 @@ class HomePresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
if (isSpaceEnabled) skipItems(1)
val initialState = awaitItem()
assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats)
initialState.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces))
@ -207,16 +179,12 @@ class HomePresenterTest {
sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
),
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.Space.key to true),
),
homeSpacesPresenter = homeSpacesPresenter,
announcementService = FakeAnnouncementService(
showAnnouncementResult = {},
)
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats)
assertThat(initialState.showNavigationBar).isTrue()
@ -241,7 +209,6 @@ internal fun createHomePresenter(
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(false) },
indicatorService: IndicatorService = FakeIndicatorService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
homeSpacesPresenter: Presenter<HomeSpacesState> = Presenter { aHomeSpacesState() },
sessionStore: SessionStore = InMemorySessionStore(),
announcementService: AnnouncementService = FakeAnnouncementService(),
@ -250,11 +217,10 @@ internal fun createHomePresenter(
syncService = syncService,
snackbarDispatcher = snackbarDispatcher,
indicatorService = indicatorService,
logoutPresenter = { aDirectLogoutState() },
roomListPresenter = { aRoomListState() },
homeSpacesPresenter = homeSpacesPresenter,
logoutPresenter = { aDirectLogoutState() },
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
featureFlagService = featureFlagService,
sessionStore = sessionStore,
announcementService = announcementService,
)

View file

@ -33,7 +33,7 @@ class RoomListSearchPresenterTest {
}.test {
awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse()
assertThat(state.query).isEmpty()
assertThat(state.query.text.toString()).isEmpty()
assertThat(state.results).isEmpty()
}
}
@ -72,10 +72,10 @@ class RoomListSearchPresenterTest {
).isEqualTo(
RoomListFilter.None
)
state.eventSink(RoomListSearchEvents.QueryChanged("Search"))
state.query.edit { append("Search") }
}
awaitItem().let { state ->
assertThat(state.query).isEqualTo("Search")
assertThat(state.query.text).isEqualTo("Search")
assertThat(
roomListService.allRooms.currentFilter.value
).isEqualTo(
@ -84,7 +84,7 @@ class RoomListSearchPresenterTest {
state.eventSink(RoomListSearchEvents.ClearQuery)
}
awaitItem().let { state ->
assertThat(state.query).isEmpty()
assertThat(state.query.text.toString()).isEmpty()
assertThat(
roomListService.allRooms.currentFilter.value
).isEqualTo(

View file

@ -1,16 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_link_new_device_desktop_scanning_title">"Scannez le QR code"</string>
<string name="screen_link_new_device_desktop_step1">"Ouvrir %1$s sur un ordinateur"</string>
<string name="screen_link_new_device_desktop_step3">"Scanner le QR code avec cet appareil"</string>
<string name="screen_link_new_device_desktop_submit">"Prêt à scanner"</string>
<string name="screen_link_new_device_desktop_title">"Ouvrir %1$s sur un ordinateur pour obtenir le code QR"</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Les nombres ne correspondent pas"</string>
<string name="screen_link_new_device_enter_number_notice">"Saisissez le code à 2 chiffres"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Cela permettra de vérifier que la connexion à votre autre appareil est sécurisée."</string>
<string name="screen_link_new_device_enter_number_title">"Saisir le nombre affiché sur votre autre appareil"</string>
<string name="screen_link_new_device_error_app_not_supported_subtitle">"Votre fournisseur de compte ne supporte pas %1$s."</string>
<string name="screen_link_new_device_error_app_not_supported_title">"%1$s nest pas supporté"</string>
<string name="screen_link_new_device_error_not_supported_subtitle">"Votre fournisseur de compte ne prend pas en charge la connexion à un nouvel appareil à laide dun code QR."</string>
<string name="screen_link_new_device_error_not_supported_title">"QR code non supporté"</string>
<string name="screen_link_new_device_error_request_cancelled_subtitle">"La connexion a été annulée sur lautre appareil."</string>
<string name="screen_link_new_device_error_request_cancelled_title">"Demande de connexion annulée"</string>
<string name="screen_link_new_device_error_request_timeout_subtitle">"Connexion expirée. Veuillez essayer à nouveau."</string>
<string name="screen_link_new_device_error_request_timeout_title">"La connexion a pris trop de temps."</string>
<string name="screen_link_new_device_mobile_step1">"Ouvrez %1$s sur lautre appareil"</string>
<string name="screen_link_new_device_mobile_step2">"Choisissez %1$s"</string>
<string name="screen_link_new_device_mobile_step2_action">"« Se connecter avec un code QR »"</string>
<string name="screen_link_new_device_mobile_step3">"Scannez ce code QR avec lautre appareil."</string>
<string name="screen_link_new_device_mobile_title">"Ouvrez %1$s sur lautre appareil"</string>
<string name="screen_link_new_device_root_desktop_computer">"Ordinateur de bureau"</string>
<string name="screen_link_new_device_root_loading_qr_code">"Chargement du code QR…"</string>
<string name="screen_link_new_device_root_mobile_device">"Appareil mobile"</string>
<string name="screen_link_new_device_root_title">"Quel type dappareil souhaitez-vous associer ?"</string>
<string name="screen_link_new_device_wrong_number_subtitle">"Veuillez réessayer et assurez-vous davoir saisi correctement le code à 2 chiffres. Si les chiffres ne correspondent toujours pas, veuillez contacter votre fournisseur de compte."</string>
<string name="screen_link_new_device_wrong_number_title">"Les nombres ne correspondent pas"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Aucune connexion sécurisée na pu être établie avec la nouvelle session. Vos sessions existantes sont toujours en sécurité et vous navez pas à vous en soucier."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Et maintenant ?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Essayez de vous connecter à nouveau à laide du QR code au cas où il sagirait dun problème réseau"</string>
@ -21,6 +38,8 @@
<string name="screen_qr_code_login_error_cancelled_title">"Demande de connexion annulée"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"La connexion a été refusée sur lautre appareil."</string>
<string name="screen_qr_code_login_error_declined_title">"Connexion refusée"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"Vous navez rien dautre à faire."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"Votre autre appareil est déjà connecté"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Connexion expirée. Veuillez essayer à nouveau."</string>
<string name="screen_qr_code_login_error_expired_title">"La connexion a pris trop de temps."</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Votre autre appareil ne supporte pas la connexion à %s avec un QR code. Essayer de vous connecter manuellement, ou scanner le QR code avec un autre appareil."</string>

View file

@ -60,6 +60,8 @@
<string name="screen_qr_code_login_error_cancelled_title">"Demande de connexion annulée"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"La connexion a été refusée sur lautre appareil."</string>
<string name="screen_qr_code_login_error_declined_title">"Connexion refusée"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"Vous navez rien dautre à faire."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"Votre autre appareil est déjà connecté"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Connexion expirée. Veuillez essayer à nouveau."</string>
<string name="screen_qr_code_login_error_expired_title">"La connexion a pris trop de temps."</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Votre autre appareil ne supporte pas la connexion à %s avec un QR code. Essayer de vous connecter manuellement, ou scanner le QR code avec un autre appareil."</string>

View file

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_account_provider_change">"Promijeni davatelja računa"</string>
<string name="screen_account_provider_change">"Promijeni davatelja usluga računa"</string>
<string name="screen_account_provider_form_hint">"Adresa matičnog poslužitelja"</string>
<string name="screen_account_provider_form_notice">"Unesite pojam za pretraživanje ili adresu domene."</string>
<string name="screen_account_provider_form_subtitle">"Potražite tvrtku, zajednicu ili privatni poslužitelj."</string>
<string name="screen_account_provider_form_title">"Pronađite davatelja računa"</string>
<string name="screen_account_provider_form_title">"Pronađite davatelja usluga računa"</string>
<string name="screen_account_provider_signin_subtitle">"Ovdje će se čuvati vaši razgovori baš kao što biste koristili davatelja usluga e-pošte za čuvanje svojih e-poruka."</string>
<string name="screen_account_provider_signin_title">"Prijavit ćete se na %s"</string>
<string name="screen_account_provider_signup_subtitle">"Ovdje će se čuvati vaši razgovori baš kao što biste koristili davatelja usluga e-pošte za čuvanje svojih e-poruka."</string>
@ -12,7 +12,7 @@
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org velik je, besplatni poslužitelj na javnoj Matrixovoj mreži koji pruža sigurnu, decentraliziranu komunikaciju, a kojim upravlja zaklada Matrix.org."</string>
<string name="screen_change_account_provider_other">"Ostalo"</string>
<string name="screen_change_account_provider_subtitle">"Koristite drugog davatelja računa, kao što je vlastiti privatni poslužitelj ili poslovni račun."</string>
<string name="screen_change_account_provider_title">"Promijeni davatelja računa"</string>
<string name="screen_change_account_provider_title">"Promijeni davatelja usluga računa"</string>
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
<string name="screen_change_server_error_element_pro_required_message">"Potrebna je aplikacija Element Pro na %1$s. Molimo vas da je preuzmete iz trgovine."</string>
<string name="screen_change_server_error_element_pro_required_title">"Potreban je Element Pro"</string>
@ -90,7 +90,7 @@ Pokušajte se prijaviti ručno ili skenirajte QR kod drugim uređajem."</string>
<string name="screen_qr_code_login_verify_code_loading">"Čekanje na vaš drugi uređaj"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Davatelj usluge računa može zatražiti sljedeći kod za potvrdu prijave."</string>
<string name="screen_qr_code_login_verify_code_title">"Vaš verifikacijski kod"</string>
<string name="screen_server_confirmation_change_server">"Promijeni davatelja računa"</string>
<string name="screen_server_confirmation_change_server">"Promijeni davatelja usluga računa"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Privatni poslužitelj za zaposlenike aplikacije Element."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix je otvorena mreža za sigurnu, decentraliziranu komunikaciju."</string>
<string name="screen_server_confirmation_message_register">"Ovdje će se čuvati vaši razgovori baš kao što biste koristili davatelja usluga e-pošte za čuvanje svojih e-poruka."</string>

View file

@ -231,7 +231,7 @@ private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) {
Text(
modifier = Modifier.weight(1f).align(Alignment.CenterVertically),
text = stringResource(R.string.screen_media_upload_preview_optimize_image_quality_title),
style = ElementTheme.materialTypography.bodyLarge,
style = ElementTheme.typography.fontBodyLgRegular,
)
Switch(
modifier = Modifier.height(32.dp),
@ -337,7 +337,7 @@ private fun VideoQualitySelectorDialog(
supportingContent = {
Text(
text = preset.subtitle(),
style = ElementTheme.materialTypography.bodyMedium,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
},

View file

@ -56,7 +56,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationTapOpensTimeline
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationToMessage
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.finishLongRunningTransaction
@ -205,7 +205,7 @@ class TimelinePresenter(
}.start()
is TimelineEvents.OnFocusEventRender -> {
// If there was a pending 'notification tap opens timeline' transaction, finish it now we're focused in the required event
analyticsService.finishLongRunningTransaction(NotificationTapOpensTimeline)
analyticsService.finishLongRunningTransaction(NotificationToMessage)
focusRequestState.value = focusRequestState.value.onFocusEventRender()
}

View file

@ -9,6 +9,7 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -42,6 +43,7 @@ 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.libraries.designsystem.atomic.atoms.PlaybackSpeedButton
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -51,7 +53,7 @@ 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.ui.utils.time.isTalkbackActive
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
import kotlinx.coroutines.delay
@ -64,26 +66,26 @@ fun TimelineItemVoiceView(
modifier: Modifier = Modifier,
) {
fun playPause() {
state.eventSink(VoiceMessageEvents.PlayPause)
state.eventSink(VoiceMessageEvent.PlayPause)
}
val a11y = stringResource(CommonStrings.common_voice_message)
val a11yActionLabel = stringResource(
when (state.button) {
VoiceMessageState.Button.Play -> CommonStrings.a11y_play
VoiceMessageState.Button.Pause -> CommonStrings.a11y_pause
VoiceMessageState.Button.Downloading -> CommonStrings.common_downloading
VoiceMessageState.Button.Retry -> CommonStrings.action_retry
VoiceMessageState.Button.Disabled -> CommonStrings.error_unknown
when (state.buttonType) {
VoiceMessageState.ButtonType.Play -> CommonStrings.a11y_play
VoiceMessageState.ButtonType.Pause -> CommonStrings.a11y_pause
VoiceMessageState.ButtonType.Downloading -> CommonStrings.common_downloading
VoiceMessageState.ButtonType.Retry -> CommonStrings.action_retry
VoiceMessageState.ButtonType.Disabled -> CommonStrings.error_unknown
}
)
Row(
modifier = modifier
.clearAndSetSemantics {
contentDescription = a11y
if (state.button == VoiceMessageState.Button.Disabled) {
if (state.buttonType == VoiceMessageState.ButtonType.Disabled) {
disabled()
} else if (state.button in listOf(VoiceMessageState.Button.Play, VoiceMessageState.Button.Pause)) {
} else if (state.buttonType in listOf(VoiceMessageState.ButtonType.Play, VoiceMessageState.ButtonType.Pause)) {
onClick(label = a11yActionLabel) {
playPause()
true
@ -101,30 +103,41 @@ fun TimelineItemVoiceView(
verticalAlignment = Alignment.CenterVertically,
) {
if (!isTalkbackActive()) {
when (state.button) {
VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.Button.Downloading -> ProgressButton()
VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false)
when (state.buttonType) {
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
}
}
Spacer(Modifier.width(8.dp))
Text(
text = state.time,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
PlaybackSpeedButton(
speed = state.playbackSpeed,
onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) },
)
Text(
text = state.time,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(8.dp))
WaveformPlaybackView(
showCursor = state.showCursor,
playbackProgress = state.progress,
waveform = content.waveform,
modifier = Modifier.height(34.dp),
modifier = Modifier
.weight(1f)
.height(34.dp),
seekEnabled = !isTalkbackActive(),
onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) },
onSeek = { state.eventSink(VoiceMessageEvent.Seek(it)) },
)
}
}

View file

@ -49,7 +49,7 @@ fun GroupHeaderView(
modifier: Modifier = Modifier
) {
// Ignore isHighlighted for now, we need a design decision on it.
val backgroundColor = Color.Companion.Transparent
val backgroundColor = Color.Transparent
val shape = RoundedCornerShape(CORNER_RADIUS)
Box(

View file

@ -9,7 +9,6 @@
package io.element.android.features.messages.impl.timeline.factories.event
import android.text.style.URLSpan
import androidx.core.text.buildSpannedString
import androidx.core.text.getSpans
import androidx.core.text.toSpannable
import dev.zacsweers.metro.Inject
@ -35,11 +34,9 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
@ -50,6 +47,7 @@ import io.element.android.libraries.matrix.ui.messages.toHtmlDocument
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.jsoup.nodes.Document
import kotlin.time.Duration
@Inject
@ -60,7 +58,7 @@ class TimelineItemContentMessageFactory(
private val permalinkParser: PermalinkParser,
private val textPillificationHelper: TextPillificationHelper,
) {
suspend fun create(
fun create(
content: MessageContent,
senderDisambiguatedDisplayName: String,
eventId: EventId?,
@ -68,26 +66,29 @@ class TimelineItemContentMessageFactory(
return when (val messageType = content.type) {
is EmoteMessageType -> {
val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}"
val formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: textPillificationHelper.pillify(
emoteBody
).safeLinkify()
val dom = messageType.formatted?.toHtmlDocument(
permalinkParser = permalinkParser,
prefix = "* $senderDisambiguatedDisplayName",
)
val formattedBody = dom?.let(::parseHtml)
?: textPillificationHelper.pillify(emoteBody).safeLinkify()
TimelineItemEmoteContent(
body = emoteBody,
htmlDocument = messageType.formatted?.toHtmlDocument(
permalinkParser = permalinkParser,
prefix = "* $senderDisambiguatedDisplayName",
),
htmlDocument = dom,
formattedBody = formattedBody,
isEdited = content.isEdited,
)
}
is ImageMessageType -> {
val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedCaption = dom?.let(::parseHtml)
?: messageType.caption?.withLinks()
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemImageContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
formattedCaption = formattedCaption,
isEdited = content.isEdited,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
@ -103,12 +104,15 @@ class TimelineItemContentMessageFactory(
)
}
is StickerMessageType -> {
val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedCaption = dom?.let(::parseHtml)
?: messageType.caption?.withLinks()
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemStickerContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
formattedCaption = formattedCaption,
isEdited = content.isEdited,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
@ -140,12 +144,15 @@ class TimelineItemContentMessageFactory(
}
}
is VideoMessageType -> {
val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedCaption = dom?.let(::parseHtml)
?: messageType.caption?.withLinks()
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemVideoContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
formattedCaption = formattedCaption,
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source,
@ -162,11 +169,14 @@ class TimelineItemContentMessageFactory(
)
}
is AudioMessageType -> {
val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedCaption = dom?.let(::parseHtml)
?: messageType.caption?.withLinks()
TimelineItemAudioContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
formattedCaption = formattedCaption,
isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
@ -176,12 +186,15 @@ class TimelineItemContentMessageFactory(
)
}
is VoiceMessageType -> {
val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedCaption = dom?.let(::parseHtml)
?: messageType.caption?.withLinks()
TimelineItemVoiceContent(
eventId = eventId,
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
formattedCaption = formattedCaption,
isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
@ -192,12 +205,15 @@ class TimelineItemContentMessageFactory(
)
}
is FileMessageType -> {
val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedCaption = dom?.let(::parseHtml)
?: messageType.caption?.withLinks()
val fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
TimelineItemFileContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
formattedCaption = formattedCaption,
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source,
@ -208,9 +224,9 @@ class TimelineItemContentMessageFactory(
}
is NoticeMessageType -> {
val body = messageType.body.trimEnd()
val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify(
body
).safeLinkify()
val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedBody = dom?.let(::parseHtml)
?: textPillificationHelper.pillify(body).safeLinkify()
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
TimelineItemNoticeContent(
body = body,
@ -221,12 +237,13 @@ class TimelineItemContentMessageFactory(
}
is TextMessageType -> {
val body = messageType.body.trimEnd()
val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify(
body
).safeLinkify()
val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedBody = dom?.let(::parseHtml)
?: textPillificationHelper.pillify(body).safeLinkify()
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
TimelineItemTextContent(
body = body,
htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
htmlDocument = htmlDocument,
formattedBody = formattedBody,
isEdited = content.isEdited,
)
@ -253,21 +270,11 @@ class TimelineItemContentMessageFactory(
return result?.takeIf { it.isFinite() }
}
private fun parseHtml(formattedBody: FormattedBody?, prefix: String? = null): CharSequence? {
if (formattedBody == null || formattedBody.format != MessageFormat.HTML) return null
val result = htmlConverterProvider.provide()
.fromHtmlToSpans(formattedBody.body.trimEnd())
private fun parseHtml(document: Document): CharSequence? {
return htmlConverterProvider.provide()
.fromDocumentToSpans(document)
.let { textPillificationHelper.pillify(it) }
.safeLinkify()
return if (prefix != null) {
buildSpannedString {
append(prefix)
append(" ")
append(result)
}
} else {
result
}
}
}

View file

@ -67,6 +67,7 @@ import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractor
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest
import org.jsoup.nodes.Document
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
@ -187,7 +188,7 @@ class TimelineItemContentMessageFactoryTest {
}
}.toSpannable()
val sut = createTimelineItemContentMessageFactory(
htmlConverterTransform = { expected }
domConverterTransform = { expected }
)
val result = sut.create(
content = createMessageContent(
@ -679,7 +680,7 @@ class TimelineItemContentMessageFactoryTest {
}
}.toSpannable()
val sut = createTimelineItemContentMessageFactory(
htmlConverterTransform = { expectedSpanned },
domConverterTransform = { expectedSpanned },
permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(Uri.EMPTY) }
)
val result = sut.create(
@ -765,11 +766,12 @@ class TimelineItemContentMessageFactoryTest {
private fun createTimelineItemContentMessageFactory(
htmlConverterTransform: (String) -> CharSequence = { it },
domConverterTransform: (Document) -> CharSequence = { it.body().html() },
permalinkParser: FakePermalinkParser = FakePermalinkParser(),
) = TimelineItemContentMessageFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform),
htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform, domConverterTransform),
permalinkParser = permalinkParser,
textPillificationHelper = FakeTextPillificationHelper(),
)

View file

@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.test.pinned
import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.timeline.FakeTimelineProvider
import kotlinx.coroutines.flow.StateFlow
class FakePinnedEventsTimelineProvider(
private val fakeTimelineProvider: FakeTimelineProvider = FakeTimelineProvider(),
) : PinnedEventsTimelineProvider {
override fun activeTimelineFlow(): StateFlow<Timeline?> = fakeTimelineProvider.activeTimelineFlow()
}

View file

@ -11,9 +11,11 @@ package io.element.android.features.messages.test.timeline
import androidx.compose.runtime.Composable
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.wysiwyg.utils.HtmlConverter
import org.jsoup.nodes.Document
class FakeHtmlConverterProvider(
private val transform: (String) -> CharSequence = { it },
private val transformDom: (Document) -> CharSequence = { it.html() },
) : HtmlConverterProvider {
@Composable
override fun Update() = Unit
@ -23,6 +25,10 @@ class FakeHtmlConverterProvider(
override fun fromHtmlToSpans(html: String): CharSequence {
return transform(html)
}
override fun fromDocumentToSpans(dom: Document): CharSequence {
return transformDom(dom)
}
}
}
}

View file

@ -234,7 +234,7 @@ private fun VideoQualitySelectorDialog(
supportingContent = {
Text(
text = subtitle,
style = ElementTheme.materialTypography.bodyMedium,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
},

View file

@ -404,7 +404,7 @@ private fun RoomHeaderSection(
}.toImmutableList(),
isTombstoned = isTombstoned,
),
contentDescription = avatarUrl?.let { stringResource(CommonStrings.a11y_room_avatar) },
contentDescription = stringResource(CommonStrings.a11y_room_avatar),
modifier = Modifier
.clickable(
enabled = avatarUrl != null,

View file

@ -26,7 +26,7 @@ data class RoomMemberListState(
val moderationState: RoomMemberModerationState,
val eventSink: (RoomMemberListEvents) -> Unit,
) {
val showBannedSection: Boolean = moderationState.permissions.hasAny && roomMembers.dataOrNull()?.banned?.isNotEmpty() == true
val showBannedSection: Boolean = moderationState.permissions.canBan && roomMembers.dataOrNull()?.banned?.isNotEmpty() == true
}
enum class SelectedSection {

View file

@ -129,8 +129,10 @@
<string name="screen_room_roles_and_permissions_room_details">"Détails du salon"</string>
<string name="screen_room_roles_and_permissions_title">"Rôles &amp; autorisations"</string>
<string name="screen_security_and_privacy_add_room_address_action">"Ajouter une adresse"</string>
<string name="screen_security_and_privacy_ask_to_join_multiple_spaces_members_option_description">"Toute personne se trouvant dans un espace autorisé peut participer, mais toutes les autres doivent demander laccès."</string>
<string name="screen_security_and_privacy_ask_to_join_option_description">"Tout le monde doit demander un accès."</string>
<string name="screen_security_and_privacy_ask_to_join_option_title">"Demander à rejoindre"</string>
<string name="screen_security_and_privacy_ask_to_join_single_space_members_option_description">"Tout membre de %1$s peut rejoindre lespace, mais les autres doivent demander un accès."</string>
<string name="screen_security_and_privacy_enable_encryption_alert_confirm_button_title">"Oui, activer le chiffrement"</string>
<string name="screen_security_and_privacy_enable_encryption_alert_description">"Une fois activé, le chiffrement dun salon ne peut pas être désactivé. Lhistorique des messages ne sera visible que pour les membres depuis quils ont été invités ou depuis quils ont rejoint le salon.
Personne dautre que les membres du salon ne pourra lire les messages. Cela peut empêcher les bots et les bridges de fonctionner correctement.
@ -155,10 +157,11 @@ Nous ne recommandons pas dactiver le chiffrement pour les salons que tout le
<string name="screen_security_and_privacy_room_directory_visibility_section_footer">"Autoriser le salon à apparaître dans les résultats de recherche dans le répertoire %1$s des salons publics"</string>
<string name="screen_security_and_privacy_room_directory_visibility_toggle_description">"Permet dêtre trouvé en recherchant dans lannuaire public."</string>
<string name="screen_security_and_privacy_room_directory_visibility_toggle_title">"Visible dans lannuaire public"</string>
<string name="screen_security_and_privacy_room_history_anyone_option_title">"Tout le monde"</string>
<string name="screen_security_and_privacy_room_history_anyone_option_title">"Tout le monde (lhistorique est public)"</string>
<string name="screen_security_and_privacy_room_history_section_footer">"Les changements naffecteront pas les anciens messages, seulement les nouveaux. %1$s"</string>
<string name="screen_security_and_privacy_room_history_section_header">"Qui peux lire lhistorique"</string>
<string name="screen_security_and_privacy_room_history_since_invite_option_title">"Les membres uniquement depuis quils ont été invités"</string>
<string name="screen_security_and_privacy_room_history_since_selecting_option_title">"Les membres uniquement depuis la sélection de cette option"</string>
<string name="screen_security_and_privacy_room_history_since_invite_option_title">"Seulement les membres, depuis leur invitation"</string>
<string name="screen_security_and_privacy_room_history_since_selecting_option_title">"Membres (historique complet)"</string>
<string name="screen_security_and_privacy_room_publishing_section_footer">"Les adresses de salon sont un moyen de trouver et daccéder aux salons. Cela vous permet également de partager facilement votre salon avec dautres personnes.
Vous pouvez choisir de publier votre salon dans lannuaire des salons publics de votre serveur daccueil."</string>
<string name="screen_security_and_privacy_room_publishing_section_header">"Publication du salon"</string>

View file

@ -13,7 +13,8 @@ data class RoomMemberModerationPermissions(
val canKick: Boolean,
val canBan: Boolean,
) {
val hasAny = canKick || canBan
// Unban requires both kick and ban permission instead of a dedicated unban permission
val canUnban = canBan && canKick
companion object {
val DEFAULT = RoomMemberModerationPermissions(

View file

@ -161,16 +161,27 @@ class RoomMemberModerationPresenter(
val canModerateThisUser = currentUserPowerLevel > targetMemberPowerLevel
// Assume the member is joined when it's unknown
val membership = member?.membership ?: RoomMembershipState.JOIN
if (permissions.canKick) {
// Unban requires kick permission instead of a dedicated unban permission
if (membership == RoomMembershipState.BAN) {
add(ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = canModerateThisUser))
} else if (membership != RoomMembershipState.LEAVE) {
add(ModerationActionState(action = ModerationAction.KickUser, isEnabled = canModerateThisUser))
when (membership) {
RoomMembershipState.BAN -> {
if (permissions.canUnban) {
add(ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = canModerateThisUser))
}
}
RoomMembershipState.INVITE,
RoomMembershipState.JOIN,
RoomMembershipState.KNOCK -> {
if (permissions.canKick) {
add(ModerationActionState(action = ModerationAction.KickUser, isEnabled = canModerateThisUser))
}
if (permissions.canBan) {
add(ModerationActionState(action = ModerationAction.BanUser, isEnabled = canModerateThisUser))
}
}
RoomMembershipState.LEAVE -> {
if (permissions.canBan) {
add(ModerationActionState(action = ModerationAction.BanUser, isEnabled = canModerateThisUser))
}
}
}
if (permissions.canBan && membership != RoomMembershipState.BAN) {
add(ModerationActionState(action = ModerationAction.BanUser, isEnabled = canModerateThisUser))
}
}.toImmutableList()
}

View file

@ -42,7 +42,7 @@
<string name="screen_recovery_key_confirm_description">"Pazite da nitko ne vidi ovaj zaslon!"</string>
<string name="screen_recovery_key_confirm_error_content">"Pokušajte ponovno potvrditi pristup pohrani ključeva."</string>
<string name="screen_recovery_key_confirm_error_title">"Neispravan ključ za oporavak"</string>
<string name="screen_recovery_key_confirm_key_description">"Ako imate sigurnosni ključ ili sigurnosnu frazu, i ovo će funkcionirati."</string>
<string name="screen_recovery_key_confirm_key_description">"Ako imate sigurnosni ključ ili sigurnosni izraz, i ovo će funkcionirati."</string>
<string name="screen_recovery_key_confirm_key_placeholder">"Unos…"</string>
<string name="screen_recovery_key_confirm_lost_recovery_key">"Izgubili ste ključ za oporavak?"</string>
<string name="screen_recovery_key_confirm_success">"Ključ za oporavak je potvrđen"</string>

View file

@ -8,8 +8,10 @@
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Autres espaces dont vous nêtes pas membre"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Vos espaces"</string>
<string name="screen_security_and_privacy_add_room_address_action">"Ajouter une adresse"</string>
<string name="screen_security_and_privacy_ask_to_join_multiple_spaces_members_option_description">"Toute personne se trouvant dans un espace autorisé peut participer, mais toutes les autres doivent demander laccès."</string>
<string name="screen_security_and_privacy_ask_to_join_option_description">"Tout le monde doit demander un accès."</string>
<string name="screen_security_and_privacy_ask_to_join_option_title">"Demander à rejoindre"</string>
<string name="screen_security_and_privacy_ask_to_join_single_space_members_option_description">"Tout membre de %1$s peut rejoindre lespace, mais les autres doivent demander un accès."</string>
<string name="screen_security_and_privacy_enable_encryption_alert_confirm_button_title">"Oui, activer le chiffrement"</string>
<string name="screen_security_and_privacy_enable_encryption_alert_description">"Une fois activé, le chiffrement dun salon ne peut pas être désactivé. Lhistorique des messages ne sera visible que pour les membres depuis quils ont été invités ou depuis quils ont rejoint le salon.
Personne dautre que les membres du salon ne pourra lire les messages. Cela peut empêcher les bots et les bridges de fonctionner correctement.
@ -34,10 +36,11 @@ Nous ne recommandons pas dactiver le chiffrement pour les salons que tout le
<string name="screen_security_and_privacy_room_directory_visibility_section_footer">"Autoriser le salon à apparaître dans les résultats de recherche dans le répertoire %1$s des salons publics"</string>
<string name="screen_security_and_privacy_room_directory_visibility_toggle_description">"Permet dêtre trouvé en recherchant dans lannuaire public."</string>
<string name="screen_security_and_privacy_room_directory_visibility_toggle_title">"Visible dans lannuaire public"</string>
<string name="screen_security_and_privacy_room_history_anyone_option_title">"Tout le monde"</string>
<string name="screen_security_and_privacy_room_history_anyone_option_title">"Tout le monde (lhistorique est public)"</string>
<string name="screen_security_and_privacy_room_history_section_footer">"Les changements naffecteront pas les anciens messages, seulement les nouveaux. %1$s"</string>
<string name="screen_security_and_privacy_room_history_section_header">"Qui peux lire lhistorique"</string>
<string name="screen_security_and_privacy_room_history_since_invite_option_title">"Les membres uniquement depuis quils ont été invités"</string>
<string name="screen_security_and_privacy_room_history_since_selecting_option_title">"Les membres uniquement depuis la sélection de cette option"</string>
<string name="screen_security_and_privacy_room_history_since_invite_option_title">"Seulement les membres, depuis leur invitation"</string>
<string name="screen_security_and_privacy_room_history_since_selecting_option_title">"Membres (historique complet)"</string>
<string name="screen_security_and_privacy_room_publishing_section_footer">"Les adresses de salon sont un moyen de trouver et daccéder aux salons. Cela vous permet également de partager facilement votre salon avec dautres personnes.
Vous pouvez choisir de publier votre salon dans lannuaire des salons publics de votre serveur daccueil."</string>
<string name="screen_security_and_privacy_room_publishing_section_header">"Publication du salon"</string>

View file

@ -115,7 +115,7 @@ private fun SpaceInfoSection(
Avatar(
avatarData = AvatarData(roomId.value, name, avatarUrl, AvatarSize.SpaceListItem),
avatarType = AvatarType.Space(),
contentDescription = avatarUrl?.let { stringResource(CommonStrings.a11y_avatar) },
contentDescription = stringResource(CommonStrings.a11y_avatar),
)
Spacer(Modifier.width(16.dp))
Column {

View file

@ -140,7 +140,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpace
onRoomClick: (SpaceRoom) -> Unit = EnsureNeverCalledWithParam(),
onShareSpace: () -> Unit = EnsureNeverCalled(),
onLeaveSpaceClick: () -> Unit = EnsureNeverCalled(),
onDetailsClick: () -> Unit = EnsureNeverCalled(),
onSettingsClick: () -> Unit = EnsureNeverCalled(),
onViewMembersClick: () -> Unit = EnsureNeverCalled(),
acceptDeclineInviteView: @Composable () -> Unit = {},
) {
@ -151,7 +151,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpace
onRoomClick = onRoomClick,
onShareSpace = onShareSpace,
onLeaveSpaceClick = onLeaveSpaceClick,
onSettingsClick = onDetailsClick,
onSettingsClick = onSettingsClick,
onViewMembersClick = onViewMembersClick,
acceptDeclineInviteView = acceptDeclineInviteView,
)

View file

@ -66,7 +66,7 @@ fun UserProfileHeaderSection(
Avatar(
avatarData = AvatarData(userId.value, userName, avatarUrl, AvatarSize.UserHeader),
avatarType = AvatarType.User,
contentDescription = avatarUrl?.let { stringResource(CommonStrings.a11y_user_avatar) },
contentDescription = stringResource(CommonStrings.a11y_user_avatar),
modifier = Modifier
.clip(CircleShape)
.clickable(