Merge branch 'develop' into feature/fga/space_members_access

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

View file

@ -66,10 +66,10 @@
-dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo -dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo
# Also needed after AGP 8.13.1 upgrade, it seems like proguard is now more aggressive on removing unused code # Also needed after AGP 8.13.1 upgrade, it seems like proguard is now more aggressive on removing unused code
-keep class org.matrix.rustcomponents.sdk.** { *;} -keep,allowshrinking class org.matrix.rustcomponents.sdk.** { *;}
-keep class uniffi.** { *;} -keep,allowshrinking class uniffi.** { *;}
-keep class io.element.android.x.di.** { *; } -keep,allowshrinking class io.element.android.x.di.** { *; }
-keepclasseswithmembernames,allowoptimization,allowshrinking class io.element.android.** { *; } -keepclasseswithmembernames,allowoptimization,allowshrinking class io.element.android.** { *; }
# Keep Metro classes # Keep Metro classes
-keep class dev.zacsweers.metro.** { *; } -keep,allowshrinking class dev.zacsweers.metro.** { *; }

View file

@ -63,6 +63,7 @@ dependencies {
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test) testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.features.forward.test) testImplementation(projects.features.forward.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.features.networkmonitor.test) testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.test)
testImplementation(projects.services.appnavstate.impl) testImplementation(projects.services.appnavstate.impl)

View file

@ -320,7 +320,7 @@ class RootFlowNode(
is ResolvedIntent.Navigation -> { is ResolvedIntent.Navigation -> {
val openingRoomFromNotification = intent.getBooleanExtra(ROOM_OPENED_FROM_NOTIFICATION, false) val openingRoomFromNotification = intent.getBooleanExtra(ROOM_OPENED_FROM_NOTIFICATION, false)
if (openingRoomFromNotification && resolvedIntent.deeplinkData is DeeplinkData.Room) { if (openingRoomFromNotification && resolvedIntent.deeplinkData is DeeplinkData.Room) {
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.NotificationTapOpensTimeline) analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.NotificationToMessage)
} }
navigateTo(resolvedIntent.deeplinkData) navigateTo(resolvedIntent.deeplinkData)
} }

View file

@ -10,8 +10,10 @@ package io.element.android.appnav.di
import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider
import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.services.analytics.api.watchers.AnalyticsSendMessageWatcher
interface TimelineBindings { interface TimelineBindings {
val timelineProvider: TimelineProvider val timelineProvider: TimelineProvider
val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider
val analyticsSendMessageWatcher: AnalyticsSendMessageWatcher
} }

View file

@ -49,7 +49,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.ui.room.LoadingRoomState import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadJoinedRoomFlow import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadJoinedRoomFlow
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.AnalyticsLongRunningTransaction.OpenRoom
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@ -128,7 +128,7 @@ class RoomFlowNode(
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
val parentTransaction = analyticsService.getLongRunningTransaction(NotificationTapOpensTimeline) val parentTransaction = analyticsService.getLongRunningTransaction(NotificationToMessage)
val openRoomTransaction = analyticsService.startLongRunningTransaction(OpenRoom, parentTransaction) val openRoomTransaction = analyticsService.startLongRunningTransaction(OpenRoom, parentTransaction)
analyticsService.startLongRunningTransaction(LoadJoinedRoomFlow, openRoomTransaction) analyticsService.startLongRunningTransaction(LoadJoinedRoomFlow, openRoomTransaction)
resolveRoomId() resolveRoomId()

View file

@ -98,6 +98,8 @@ class JoinedRoomLoadedFlowNode(
private val callback: Callback = callback() private val callback: Callback = callback()
override val graph = roomGraphFactory.create(inputs.room) override val graph = roomGraphFactory.create(inputs.room)
private val sendMessageWatcher = (graph as? TimelineBindings)?.analyticsSendMessageWatcher
// This is an ugly hack to check activity recreation // This is an ugly hack to check activity recreation
private var currentActivity: Activity? = null private var currentActivity: Activity? = null
@ -109,6 +111,7 @@ class JoinedRoomLoadedFlowNode(
Timber.v("OnCreate => ${inputs.room.roomId}") Timber.v("OnCreate => ${inputs.room.roomId}")
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId) appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
activeRoomsHolder.addRoom(inputs.room) activeRoomsHolder.addRoom(inputs.room)
sendMessageWatcher?.start()
fetchRoomMembers() fetchRoomMembers()
trackVisitedRoom() trackVisitedRoom()
}, },
@ -120,6 +123,7 @@ class JoinedRoomLoadedFlowNode(
}, },
onDestroy = { onDestroy = {
Timber.v("OnDestroy") Timber.v("OnDestroy")
sendMessageWatcher?.stop()
// If we're just going through an activity recreation there's no need to destroy the Room object // If we're just going through an activity recreation there's no need to destroy the Room object
// Destroying it would actually cause an issue where its methods can no longer be called // Destroying it would actually cause an issue where its methods can no longer be called
if (currentActivity?.isChangingConfigurations != true) { if (currentActivity?.isChangingConfigurations != true) {

View file

@ -19,22 +19,29 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.appnav.di.RoomGraphFactory import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.appnav.di.TimelineBindings
import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.FakeJoinedRoomLoadedFlowNodeCallback import io.element.android.appnav.room.joined.FakeJoinedRoomLoadedFlowNodeCallback
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.features.forward.api.ForwardEntryPoint import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.forward.test.FakeForwardEntryPoint import io.element.android.features.forward.test.FakeForwardEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider
import io.element.android.features.messages.test.pinned.FakePinnedEventsTimelineProvider
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.childNode import io.element.android.libraries.architecture.childNode
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.timeline.FakeTimelineProvider
import io.element.android.services.analytics.api.watchers.AnalyticsSendMessageWatcher
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.analytics.test.watchers.FakeAnalyticsSendMessageWatcher
import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
@ -72,9 +79,20 @@ class JoinedRoomLoadedFlowNodeTest {
} }
} }
private class FakeRoomGraphFactory : RoomGraphFactory { private class FakeRoomGraphFactory(
private val timelineProvider: FakeTimelineProvider = FakeTimelineProvider(),
private val pinnedEventsTimelineProvider: FakePinnedEventsTimelineProvider = FakePinnedEventsTimelineProvider(),
private val analyticsSendMessageWatcher: FakeAnalyticsSendMessageWatcher = FakeAnalyticsSendMessageWatcher(),
) : RoomGraphFactory {
override fun create(room: JoinedRoom): Any { override fun create(room: JoinedRoom): Any {
return Unit return object : TimelineBindings {
override val timelineProvider: TimelineProvider
get() = this@FakeRoomGraphFactory.timelineProvider
override val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider
get() = this@FakeRoomGraphFactory.pinnedEventsTimelineProvider
override val analyticsSendMessageWatcher: AnalyticsSendMessageWatcher
get() = this@FakeRoomGraphFactory.analyticsSendMessageWatcher
}
} }
} }

View file

@ -91,7 +91,7 @@ class ContributesNodeProcessor(
.addAnnotation(Binds::class) .addAnnotation(Binds::class)
.addAnnotation(IntoMap::class) .addAnnotation(IntoMap::class)
.addAnnotation( .addAnnotation(
AnnotationSpec.Companion.builder(ClassName.bestGuess(nodeKeyFqName.asString())).addMember( AnnotationSpec.builder(ClassName.bestGuess(nodeKeyFqName.asString())).addMember(
CLASS_PLACEHOLDER, CLASS_PLACEHOLDER,
ClassName.bestGuess(ksClass.qualifiedName!!.asString()) ClassName.bestGuess(ksClass.qualifiedName!!.asString())
).build() ).build()

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,16 +18,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester 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.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
@ -112,23 +108,14 @@ private fun RoomListSearchContent(
}, },
navigationIcon = { BackButton(onClick = ::onBackButtonClick) }, navigationIcon = { BackButton(onClick = ::onBackButtonClick) },
title = { 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 // 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() } val focusRequester = remember { FocusRequester() }
FilledTextField( FilledTextField(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(focusRequester), .focusRequester(focusRequester),
value = value, state = state.query,
singleLine = true, lineLimits = TextFieldLineLimits.SingleLine,
onValueChange = {
value = it
state.eventSink(RoomListSearchEvents.QueryChanged(it.text))
},
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent,
@ -138,20 +125,18 @@ private fun RoomListSearchContent(
disabledIndicatorColor = Color.Transparent, disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent, errorIndicatorColor = Color.Transparent,
), ),
trailingIcon = { trailingIcon = if (state.query.text.isNotEmpty()) {
if (value.text.isNotEmpty()) { @Composable {
IconButton(onClick = { IconButton(onClick = { state.eventSink(RoomListSearchEvents.ClearQuery) }) {
state.eventSink(RoomListSearchEvents.ClearQuery)
// Clear local state too
value = value.copy(text = "")
}) {
Icon( Icon(
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_cancel) contentDescription = stringResource(CommonStrings.action_cancel)
) )
} }
} }
} } else {
null
},
) )
LaunchedEffect(Unit) { LaunchedEffect(Unit) {

View file

@ -30,13 +30,13 @@
<string name="screen_roomlist_empty_title">"Još nema razgovora."</string> <string name="screen_roomlist_empty_title">"Još nema razgovora."</string>
<string name="screen_roomlist_filter_favourites">"Favoriti"</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. <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_favourites_empty_state_title">"Još nemate omiljenih razgovora"</string>
<string name="screen_roomlist_filter_invites">"Pozivnice"</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_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">"Nizak prioritet"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Još nemate razgovora niskog prioriteta"</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_mixed_empty_state_title">"Nemate razgovora za ovaj odabir"</string>
<string name="screen_roomlist_filter_people">"Osobe"</string> <string name="screen_roomlist_filter_people">"Osobe"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Nemate još nijednu izravnu poruku"</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.features.rageshake.test.logs.FakeAnnouncementService
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher 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.api.IndicatorService
import io.element.android.libraries.indicator.test.FakeIndicatorService import io.element.android.libraries.indicator.test.FakeIndicatorService
import io.element.android.libraries.matrix.api.MatrixClient 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_ID
import io.element.android.libraries.matrix.test.A_USER_NAME 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.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
@ -54,8 +50,6 @@ class HomePresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
private val isSpaceEnabled = FeatureFlags.Space.defaultValue(aBuildMeta())
@Test @Test
fun `present - should start with no user and then load user with success`() = runTest { fun `present - should start with no user and then load user with success`() = runTest {
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
@ -79,7 +73,6 @@ class HomePresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
if (isSpaceEnabled) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo( assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(
MatrixUser(A_USER_ID, null, null) MatrixUser(A_USER_ID, null, null)
@ -91,8 +84,7 @@ class HomePresenterTest {
MatrixUser(A_USER_ID, A_USER_NAME, AN_AVATAR_URL) MatrixUser(A_USER_ID, A_USER_NAME, AN_AVATAR_URL)
) )
assertThat(withUserState.showAvatarIndicator).isFalse() assertThat(withUserState.showAvatarIndicator).isFalse()
assertThat(withUserState.isSpaceFeatureEnabled).isEqualTo(isSpaceEnabled) assertThat(withUserState.showNavigationBar).isTrue()
assertThat(withUserState.showNavigationBar).isEqualTo(isSpaceEnabled)
} }
} }
@ -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 @Test
fun `present - show avatar indicator`() = runTest { fun `present - show avatar indicator`() = runTest {
val indicatorService = FakeIndicatorService() val indicatorService = FakeIndicatorService()
@ -143,7 +118,6 @@ class HomePresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
if (isSpaceEnabled) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showAvatarIndicator).isFalse() assertThat(initialState.showAvatarIndicator).isFalse()
indicatorService.setShowRoomListTopBarIndicator(true) indicatorService.setShowRoomListTopBarIndicator(true)
@ -168,7 +142,6 @@ class HomePresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
if (isSpaceEnabled) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(MatrixUser(matrixClient.sessionId)) assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(MatrixUser(matrixClient.sessionId))
// No new state is coming // No new state is coming
@ -189,7 +162,6 @@ class HomePresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
if (isSpaceEnabled) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats) assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats)
initialState.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces)) initialState.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces))
@ -207,16 +179,12 @@ class HomePresenterTest {
sessionStore = InMemorySessionStore( sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> }, updateUserProfileResult = { _, _, _ -> },
), ),
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.Space.key to true),
),
homeSpacesPresenter = homeSpacesPresenter, homeSpacesPresenter = homeSpacesPresenter,
announcementService = FakeAnnouncementService( announcementService = FakeAnnouncementService(
showAnnouncementResult = {}, showAnnouncementResult = {},
) )
) )
presenter.test { presenter.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats) assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats)
assertThat(initialState.showNavigationBar).isTrue() assertThat(initialState.showNavigationBar).isTrue()
@ -241,7 +209,6 @@ internal fun createHomePresenter(
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(false) }, rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(false) },
indicatorService: IndicatorService = FakeIndicatorService(), indicatorService: IndicatorService = FakeIndicatorService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
homeSpacesPresenter: Presenter<HomeSpacesState> = Presenter { aHomeSpacesState() }, homeSpacesPresenter: Presenter<HomeSpacesState> = Presenter { aHomeSpacesState() },
sessionStore: SessionStore = InMemorySessionStore(), sessionStore: SessionStore = InMemorySessionStore(),
announcementService: AnnouncementService = FakeAnnouncementService(), announcementService: AnnouncementService = FakeAnnouncementService(),
@ -250,11 +217,10 @@ internal fun createHomePresenter(
syncService = syncService, syncService = syncService,
snackbarDispatcher = snackbarDispatcher, snackbarDispatcher = snackbarDispatcher,
indicatorService = indicatorService, indicatorService = indicatorService,
logoutPresenter = { aDirectLogoutState() },
roomListPresenter = { aRoomListState() }, roomListPresenter = { aRoomListState() },
homeSpacesPresenter = homeSpacesPresenter, homeSpacesPresenter = homeSpacesPresenter,
logoutPresenter = { aDirectLogoutState() },
rageshakeFeatureAvailability = rageshakeFeatureAvailability, rageshakeFeatureAvailability = rageshakeFeatureAvailability,
featureFlagService = featureFlagService,
sessionStore = sessionStore, sessionStore = sessionStore,
announcementService = announcementService, announcementService = announcementService,
) )

View file

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

View file

@ -1,16 +1,33 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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_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_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_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_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_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_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_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_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">"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_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_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> <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_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_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_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_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_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> <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_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_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_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_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_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> <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"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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_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_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_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> <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_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_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_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_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_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> <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_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_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_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_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_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> <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( Text(
modifier = Modifier.weight(1f).align(Alignment.CenterVertically), modifier = Modifier.weight(1f).align(Alignment.CenterVertically),
text = stringResource(R.string.screen_media_upload_preview_optimize_image_quality_title), text = stringResource(R.string.screen_media_upload_preview_optimize_image_quality_title),
style = ElementTheme.materialTypography.bodyLarge, style = ElementTheme.typography.fontBodyLgRegular,
) )
Switch( Switch(
modifier = Modifier.height(32.dp), modifier = Modifier.height(32.dp),
@ -337,7 +337,7 @@ private fun VideoQualitySelectorDialog(
supportingContent = { supportingContent = {
Text( Text(
text = preset.subtitle(), text = preset.subtitle(),
style = ElementTheme.materialTypography.bodyMedium, style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary, 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.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore 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.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.AnalyticsLongRunningTransaction.OpenRoom
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.finishLongRunningTransaction import io.element.android.services.analytics.api.finishLongRunningTransaction
@ -205,7 +205,7 @@ class TimelinePresenter(
}.start() }.start()
is TimelineEvents.OnFocusEventRender -> { is TimelineEvents.OnFocusEventRender -> {
// If there was a pending 'notification tap opens timeline' transaction, finish it now we're focused in the required event // 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() focusRequestState.value = focusRequestState.value.onFocusEventRender()
} }

View file

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

View file

@ -9,7 +9,6 @@
package io.element.android.features.messages.impl.timeline.factories.event package io.element.android.features.messages.impl.timeline.factories.event
import android.text.style.URLSpan import android.text.style.URLSpan
import androidx.core.text.buildSpannedString
import androidx.core.text.getSpans import androidx.core.text.getSpans
import androidx.core.text.toSpannable import androidx.core.text.toSpannable
import dev.zacsweers.metro.Inject 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.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType 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.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.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType 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.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.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType 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 io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import org.jsoup.nodes.Document
import kotlin.time.Duration import kotlin.time.Duration
@Inject @Inject
@ -60,7 +58,7 @@ class TimelineItemContentMessageFactory(
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val textPillificationHelper: TextPillificationHelper, private val textPillificationHelper: TextPillificationHelper,
) { ) {
suspend fun create( fun create(
content: MessageContent, content: MessageContent,
senderDisambiguatedDisplayName: String, senderDisambiguatedDisplayName: String,
eventId: EventId?, eventId: EventId?,
@ -68,26 +66,29 @@ class TimelineItemContentMessageFactory(
return when (val messageType = content.type) { return when (val messageType = content.type) {
is EmoteMessageType -> { is EmoteMessageType -> {
val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}" val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}"
val formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: textPillificationHelper.pillify( val dom = messageType.formatted?.toHtmlDocument(
emoteBody permalinkParser = permalinkParser,
).safeLinkify() prefix = "* $senderDisambiguatedDisplayName",
)
val formattedBody = dom?.let(::parseHtml)
?: textPillificationHelper.pillify(emoteBody).safeLinkify()
TimelineItemEmoteContent( TimelineItemEmoteContent(
body = emoteBody, body = emoteBody,
htmlDocument = messageType.formatted?.toHtmlDocument( htmlDocument = dom,
permalinkParser = permalinkParser,
prefix = "* $senderDisambiguatedDisplayName",
),
formattedBody = formattedBody, formattedBody = formattedBody,
isEdited = content.isEdited, isEdited = content.isEdited,
) )
} }
is ImageMessageType -> { 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) val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemImageContent( TimelineItemImageContent(
filename = messageType.filename, filename = messageType.filename,
fileSize = messageType.info?.size ?: 0, fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(), caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), formattedCaption = formattedCaption,
isEdited = content.isEdited, isEdited = content.isEdited,
mediaSource = messageType.source, mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource, thumbnailSource = messageType.info?.thumbnailSource,
@ -103,12 +104,15 @@ class TimelineItemContentMessageFactory(
) )
} }
is StickerMessageType -> { 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) val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemStickerContent( TimelineItemStickerContent(
filename = messageType.filename, filename = messageType.filename,
fileSize = messageType.info?.size ?: 0, fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(), caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), formattedCaption = formattedCaption,
isEdited = content.isEdited, isEdited = content.isEdited,
mediaSource = messageType.source, mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource, thumbnailSource = messageType.info?.thumbnailSource,
@ -140,12 +144,15 @@ class TimelineItemContentMessageFactory(
} }
} }
is VideoMessageType -> { 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) val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemVideoContent( TimelineItemVideoContent(
filename = messageType.filename, filename = messageType.filename,
fileSize = messageType.info?.size ?: 0, fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(), caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), formattedCaption = formattedCaption,
isEdited = content.isEdited, isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource, thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source, mediaSource = messageType.source,
@ -162,11 +169,14 @@ class TimelineItemContentMessageFactory(
) )
} }
is AudioMessageType -> { is AudioMessageType -> {
val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedCaption = dom?.let(::parseHtml)
?: messageType.caption?.withLinks()
TimelineItemAudioContent( TimelineItemAudioContent(
filename = messageType.filename, filename = messageType.filename,
fileSize = messageType.info?.size ?: 0, fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(), caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), formattedCaption = formattedCaption,
isEdited = content.isEdited, isEdited = content.isEdited,
mediaSource = messageType.source, mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO, duration = messageType.info?.duration ?: Duration.ZERO,
@ -176,12 +186,15 @@ class TimelineItemContentMessageFactory(
) )
} }
is VoiceMessageType -> { is VoiceMessageType -> {
val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedCaption = dom?.let(::parseHtml)
?: messageType.caption?.withLinks()
TimelineItemVoiceContent( TimelineItemVoiceContent(
eventId = eventId, eventId = eventId,
filename = messageType.filename, filename = messageType.filename,
fileSize = messageType.info?.size ?: 0, fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(), caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), formattedCaption = formattedCaption,
isEdited = content.isEdited, isEdited = content.isEdited,
mediaSource = messageType.source, mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO, duration = messageType.info?.duration ?: Duration.ZERO,
@ -192,12 +205,15 @@ class TimelineItemContentMessageFactory(
) )
} }
is FileMessageType -> { is FileMessageType -> {
val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedCaption = dom?.let(::parseHtml)
?: messageType.caption?.withLinks()
val fileExtension = fileExtensionExtractor.extractFromName(messageType.filename) val fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
TimelineItemFileContent( TimelineItemFileContent(
filename = messageType.filename, filename = messageType.filename,
fileSize = messageType.info?.size ?: 0, fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(), caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), formattedCaption = formattedCaption,
isEdited = content.isEdited, isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource, thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source, mediaSource = messageType.source,
@ -208,9 +224,9 @@ class TimelineItemContentMessageFactory(
} }
is NoticeMessageType -> { is NoticeMessageType -> {
val body = messageType.body.trimEnd() val body = messageType.body.trimEnd()
val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify( val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
body val formattedBody = dom?.let(::parseHtml)
).safeLinkify() ?: textPillificationHelper.pillify(body).safeLinkify()
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser) val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
TimelineItemNoticeContent( TimelineItemNoticeContent(
body = body, body = body,
@ -221,12 +237,13 @@ class TimelineItemContentMessageFactory(
} }
is TextMessageType -> { is TextMessageType -> {
val body = messageType.body.trimEnd() val body = messageType.body.trimEnd()
val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify( val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
body val formattedBody = dom?.let(::parseHtml)
).safeLinkify() ?: textPillificationHelper.pillify(body).safeLinkify()
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
TimelineItemTextContent( TimelineItemTextContent(
body = body, body = body,
htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser), htmlDocument = htmlDocument,
formattedBody = formattedBody, formattedBody = formattedBody,
isEdited = content.isEdited, isEdited = content.isEdited,
) )
@ -253,21 +270,11 @@ class TimelineItemContentMessageFactory(
return result?.takeIf { it.isFinite() } return result?.takeIf { it.isFinite() }
} }
private fun parseHtml(formattedBody: FormattedBody?, prefix: String? = null): CharSequence? { private fun parseHtml(document: Document): CharSequence? {
if (formattedBody == null || formattedBody.format != MessageFormat.HTML) return null return htmlConverterProvider.provide()
val result = htmlConverterProvider.provide() .fromDocumentToSpans(document)
.fromHtmlToSpans(formattedBody.body.trimEnd())
.let { textPillificationHelper.pillify(it) } .let { textPillificationHelper.pillify(it) }
.safeLinkify() .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.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.jsoup.nodes.Document
import org.junit.Assert.fail import org.junit.Assert.fail
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -187,7 +188,7 @@ class TimelineItemContentMessageFactoryTest {
} }
}.toSpannable() }.toSpannable()
val sut = createTimelineItemContentMessageFactory( val sut = createTimelineItemContentMessageFactory(
htmlConverterTransform = { expected } domConverterTransform = { expected }
) )
val result = sut.create( val result = sut.create(
content = createMessageContent( content = createMessageContent(
@ -679,7 +680,7 @@ class TimelineItemContentMessageFactoryTest {
} }
}.toSpannable() }.toSpannable()
val sut = createTimelineItemContentMessageFactory( val sut = createTimelineItemContentMessageFactory(
htmlConverterTransform = { expectedSpanned }, domConverterTransform = { expectedSpanned },
permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(Uri.EMPTY) } permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(Uri.EMPTY) }
) )
val result = sut.create( val result = sut.create(
@ -765,11 +766,12 @@ class TimelineItemContentMessageFactoryTest {
private fun createTimelineItemContentMessageFactory( private fun createTimelineItemContentMessageFactory(
htmlConverterTransform: (String) -> CharSequence = { it }, htmlConverterTransform: (String) -> CharSequence = { it },
domConverterTransform: (Document) -> CharSequence = { it.body().html() },
permalinkParser: FakePermalinkParser = FakePermalinkParser(), permalinkParser: FakePermalinkParser = FakePermalinkParser(),
) = TimelineItemContentMessageFactory( ) = TimelineItemContentMessageFactory(
fileSizeFormatter = FakeFileSizeFormatter(), fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform), htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform, domConverterTransform),
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
textPillificationHelper = FakeTextPillificationHelper(), 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 androidx.compose.runtime.Composable
import io.element.android.features.messages.api.timeline.HtmlConverterProvider import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.wysiwyg.utils.HtmlConverter import io.element.android.wysiwyg.utils.HtmlConverter
import org.jsoup.nodes.Document
class FakeHtmlConverterProvider( class FakeHtmlConverterProvider(
private val transform: (String) -> CharSequence = { it }, private val transform: (String) -> CharSequence = { it },
private val transformDom: (Document) -> CharSequence = { it.html() },
) : HtmlConverterProvider { ) : HtmlConverterProvider {
@Composable @Composable
override fun Update() = Unit override fun Update() = Unit
@ -23,6 +25,10 @@ class FakeHtmlConverterProvider(
override fun fromHtmlToSpans(html: String): CharSequence { override fun fromHtmlToSpans(html: String): CharSequence {
return transform(html) return transform(html)
} }
override fun fromDocumentToSpans(dom: Document): CharSequence {
return transformDom(dom)
}
} }
} }
} }

View file

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

View file

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

View file

@ -26,7 +26,7 @@ data class RoomMemberListState(
val moderationState: RoomMemberModerationState, val moderationState: RoomMemberModerationState,
val eventSink: (RoomMemberListEvents) -> Unit, 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 { 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_room_details">"Détails du salon"</string>
<string name="screen_room_roles_and_permissions_title">"Rôles &amp; autorisations"</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_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_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_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_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. <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. 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_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_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_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_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_invite_option_title">"Seulement les membres, depuis leur invitation"</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_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. <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> 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> <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 canKick: Boolean,
val canBan: 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 { companion object {
val DEFAULT = RoomMemberModerationPermissions( val DEFAULT = RoomMemberModerationPermissions(

View file

@ -161,16 +161,27 @@ class RoomMemberModerationPresenter(
val canModerateThisUser = currentUserPowerLevel > targetMemberPowerLevel val canModerateThisUser = currentUserPowerLevel > targetMemberPowerLevel
// Assume the member is joined when it's unknown // Assume the member is joined when it's unknown
val membership = member?.membership ?: RoomMembershipState.JOIN val membership = member?.membership ?: RoomMembershipState.JOIN
if (permissions.canKick) { when (membership) {
// Unban requires kick permission instead of a dedicated unban permission RoomMembershipState.BAN -> {
if (membership == RoomMembershipState.BAN) { if (permissions.canUnban) {
add(ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = canModerateThisUser)) add(ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = canModerateThisUser))
} else if (membership != RoomMembershipState.LEAVE) { }
add(ModerationActionState(action = ModerationAction.KickUser, 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() }.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_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_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_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_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_lost_recovery_key">"Izgubili ste ključ za oporavak?"</string>
<string name="screen_recovery_key_confirm_success">"Ključ za oporavak je potvrđen"</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_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_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_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_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_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_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. <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. 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_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_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_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_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_invite_option_title">"Seulement les membres, depuis leur invitation"</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_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. <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> 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> <string name="screen_security_and_privacy_room_publishing_section_header">"Publication du salon"</string>

View file

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

View file

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

View file

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

View file

@ -22,7 +22,7 @@ camera = "1.5.2"
work = "2.11.0" work = "2.11.0"
# Compose # Compose
compose_bom = "2025.12.00" compose_bom = "2025.12.01"
# Coroutines # Coroutines
coroutines = "1.10.2" coroutines = "1.10.2"
@ -32,7 +32,7 @@ accompanist = "0.37.3"
# Test # Test
test_core = "1.7.0" test_core = "1.7.0"
roborazzi = "1.52.0" roborazzi = "1.55.0"
# Jetbrain # Jetbrain
datetime = "0.7.1" datetime = "0.7.1"
@ -44,7 +44,7 @@ coil = "3.3.0"
showkase = "1.0.5" showkase = "1.0.5"
appyx = "1.7.1" appyx = "1.7.1"
sqldelight = "2.2.1" sqldelight = "2.2.1"
wysiwyg = "2.40.0" wysiwyg = "2.41.0"
telephoto = "0.18.0" telephoto = "0.18.0"
haze = "1.7.1" haze = "1.7.1"
@ -52,7 +52,7 @@ haze = "1.7.1"
dependencyAnalysis = "3.5.1" dependencyAnalysis = "3.5.1"
# DI # DI
metro = "0.9.2" metro = "0.9.3"
# Auto service # Auto service
autoservice = "1.1.1" autoservice = "1.1.1"
@ -177,7 +177,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version
# https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt # https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt
# All new features should not be implemented in the pull request that upgrades the version, developers should # All new features should not be implemented in the pull request that upgrades the version, developers should
# only fix API breaks and may add some TODOs. # only fix API breaks and may add some TODOs.
matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.12.19" matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.1.7"
# Others # Others
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
@ -201,7 +201,7 @@ sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqlcipher = "net.zetetic:sqlcipher-android:4.12.0" sqlcipher = "net.zetetic:sqlcipher-android:4.12.0"
sqlite = "androidx.sqlite:sqlite-ktx:2.6.2" sqlite = "androidx.sqlite:sqlite-ktx:2.6.2"
unifiedpush = "org.unifiedpush.android:connector:3.1.2" unifiedpush = "org.unifiedpush.android:connector:3.2.0"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" } telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
@ -218,7 +218,7 @@ haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref =
color_picker = "io.mhssn:colorpicker:1.0.0" color_picker = "io.mhssn:colorpicker:1.0.0"
# Analytics # Analytics
posthog = "com.posthog:posthog-android:3.27.2" posthog = "com.posthog:posthog-android:3.28.0"
sentry = "io.sentry:sentry-android:8.29.0" sentry = "io.sentry:sentry-android:8.29.0"
# main branch can be tested replacing the version with main-SNAPSHOT # main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.29.2" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.29.2"

View file

@ -10,14 +10,15 @@ package io.element.android.libraries.androidutils.json
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Provider
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
/** /**
* Provides a Json instance configured to ignore unknown keys. * Provides a Json instance configured to ignore unknown keys.
*/ */
typealias JsonProvider = Provider<Json> fun interface JsonProvider {
operator fun invoke(): Json
}
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@SingleIn(AppScope::class) @SingleIn(AppScope::class)

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ac84a7175c4a4897aa28eddcf722b7997c6576f612eb38fa09ffabcf7be11e00 oid sha256:32b12d0b26cd016a632a4cb87b71d5efcb2c0d816bf565bc90aee9963ce2d5df
size 119496 size 134117

View file

@ -17,13 +17,14 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.compoundTypography
@Preview @Preview
@Composable @Composable
internal fun TypographyPreview() = ElementTheme { internal fun TypographyPreview() = ElementTheme {
Surface { Surface {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
with(ElementTheme.materialTypography) { with(compoundTypography) {
TypographyTokenPreview(displayLarge, "Display large") TypographyTokenPreview(displayLarge, "Display large")
TypographyTokenPreview(displayMedium, "Display medium") TypographyTokenPreview(displayMedium, "Display medium")
TypographyTokenPreview(displaySmall, "Display small") TypographyTokenPreview(displaySmall, "Display small")
@ -44,6 +45,33 @@ internal fun TypographyPreview() = ElementTheme {
} }
} }
@Preview
@Composable
internal fun CompoundTypographyPreview() = ElementTheme {
Surface {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
with(ElementTheme.typography) {
TypographyTokenPreview(fontHeadingXlBold, "fontHeadingXlBold")
TypographyTokenPreview(fontHeadingXlRegular, "fontHeadingXlRegular")
TypographyTokenPreview(fontHeadingLgBold, "fontHeadingLgBold")
TypographyTokenPreview(fontHeadingLgRegular, "fontHeadingLgRegular")
TypographyTokenPreview(fontHeadingMdBold, "fontHeadingMdBold")
TypographyTokenPreview(fontHeadingMdRegular, "fontHeadingMdRegular")
TypographyTokenPreview(fontHeadingSmMedium, "fontHeadingSmMedium")
TypographyTokenPreview(fontHeadingSmRegular, "fontHeadingSmRegular")
TypographyTokenPreview(fontBodyLgMedium, "fontBodyLgMedium")
TypographyTokenPreview(fontBodyLgRegular, "fontBodyLgRegular")
TypographyTokenPreview(fontBodyMdMedium, "fontBodyMdMedium")
TypographyTokenPreview(fontBodyMdRegular, "fontBodyMdRegular")
TypographyTokenPreview(fontBodySmMedium, "fontBodySmMedium")
TypographyTokenPreview(fontBodySmRegular, "fontBodySmRegular")
TypographyTokenPreview(fontBodyXsMedium, "fontBodyXsMedium")
TypographyTokenPreview(fontBodyXsRegular, "fontBodyXsRegular")
}
}
}
}
@Composable @Composable
private fun TypographyTokenPreview(style: TextStyle, text: String) { private fun TypographyTokenPreview(style: TextStyle, text: String) {
Text(text = text, style = style) Text(text = text, style = style)

View file

@ -62,14 +62,6 @@ object ElementTheme {
*/ */
val typography: TypographyTokens = TypographyTokens val typography: TypographyTokens = TypographyTokens
/**
* Material 3 [Typography] tokens. In Figma, these have the `M3 Typography/` prefix.
*/
val materialTypography: Typography
@Composable
@ReadOnlyComposable
get() = MaterialTheme.typography
/** /**
* Returns whether the theme version used is the light or the dark one. * Returns whether the theme version used is the light or the dark one.
*/ */

View file

@ -8,18 +8,10 @@
package io.element.android.compound.screenshot package io.element.android.compound.screenshot
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.previews.CompoundTypographyPreview
import io.element.android.compound.screenshot.utils.screenshotFile import io.element.android.compound.screenshot.utils.screenshotFile
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.TypographyTokens
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@ -32,35 +24,7 @@ class CompoundTypographyTest {
@Config(sdk = [35], qualifiers = "h2048dp-xxhdpi") @Config(sdk = [35], qualifiers = "h2048dp-xxhdpi")
fun screenshots() { fun screenshots() {
captureRoboImage(file = screenshotFile("Compound Typography.png")) { captureRoboImage(file = screenshotFile("Compound Typography.png")) {
ElementTheme { CompoundTypographyPreview()
Surface {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
with(TypographyTokens) {
TypographyTokenPreview(fontHeadingXlBold, "Heading XL Bold")
TypographyTokenPreview(fontHeadingXlRegular, "Heading XL Regular")
TypographyTokenPreview(fontHeadingLgBold, "Heading LG Bold")
TypographyTokenPreview(fontHeadingLgRegular, "Heading LG Regular")
TypographyTokenPreview(fontHeadingMdBold, "Heading MD Bold")
TypographyTokenPreview(fontHeadingMdRegular, "Heading MD Regular")
TypographyTokenPreview(fontHeadingSmMedium, "Heading SM Medium")
TypographyTokenPreview(fontHeadingSmRegular, "Heading SM Regular")
TypographyTokenPreview(fontBodyLgMedium, "Body LG Medium")
TypographyTokenPreview(fontBodyLgRegular, "Body LG Regular")
TypographyTokenPreview(fontBodyMdMedium, "Body MD Medium")
TypographyTokenPreview(fontBodyMdRegular, "Body MD Regular")
TypographyTokenPreview(fontBodySmMedium, "Body SM Medium")
TypographyTokenPreview(fontBodySmRegular, "Body SM Regular")
TypographyTokenPreview(fontBodyXsMedium, "Body XS Medium")
TypographyTokenPreview(fontBodyXsRegular, "Body XS Regular")
}
}
}
}
} }
} }
@Composable
private fun TypographyTokenPreview(style: TextStyle, text: String) {
Text(text = text, style = style)
}
} }

View file

@ -0,0 +1,75 @@
/*
* 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.libraries.designsystem.atomic.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.messageFromMeBackground
@Composable
fun PlaybackSpeedButton(
speed: Float,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val speedText = when (speed) {
0.5f -> "0.5×"
1.0f -> "1×"
1.5f -> "1.5×"
2.0f -> "2×"
else -> "$speed×"
}
Box(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(
color = ElementTheme.colors.bgCanvasDefault,
)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 4.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = speedText,
color = ElementTheme.colors.iconSecondary,
style = ElementTheme.typography.fontBodyXsMedium,
)
}
}
@PreviewsDayNight
@Composable
internal fun PlaybackSpeedButtonPreview() = ElementPreview {
Row(
modifier = Modifier
.background(ElementTheme.colors.messageFromMeBackground)
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf(0.5f, 1.0f, 1.5f, 2.0f, 3.0f).forEach { speed ->
PlaybackSpeedButton(
speed = speed,
onClick = {},
)
}
}
}

View file

@ -34,7 +34,7 @@ fun SelectedIndicatorAtom(
Icon( Icon(
modifier = modifier.toggleable( modifier = modifier.toggleable(
value = true, value = true,
role = Role.Companion.Checkbox, role = Role.Checkbox,
enabled = enabled, enabled = enabled,
onValueChange = {}, onValueChange = {},
), ),

View file

@ -59,7 +59,7 @@ fun DmAvatars(
Avatar( Avatar(
avatarData = userAvatarData, avatarData = userAvatarData,
avatarType = AvatarType.User, avatarType = AvatarType.User,
contentDescription = userAvatarData.url?.let { stringResource(CommonStrings.a11y_your_avatar) }, contentDescription = stringResource(CommonStrings.a11y_your_avatar),
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.graphicsLayer { .graphicsLayer {
@ -94,7 +94,7 @@ fun DmAvatars(
Avatar( Avatar(
avatarData = otherUserAvatarData, avatarData = otherUserAvatarData,
avatarType = AvatarType.User, avatarType = AvatarType.User,
contentDescription = otherUserAvatarData.url?.let { stringResource(CommonStrings.a11y_other_user_avatar) }, contentDescription = stringResource(CommonStrings.a11y_other_user_avatar),
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.clip(CircleShape) .clip(CircleShape)

View file

@ -36,7 +36,7 @@ internal fun ImageAvatar(
SubcomposeAsyncImage( SubcomposeAsyncImage(
model = avatarData, model = avatarData,
contentDescription = contentDescription, contentDescription = contentDescription,
contentScale = ContentScale.Companion.Crop, contentScale = ContentScale.Crop,
modifier = modifier modifier = modifier
.size(size) .size(size)
.clip(avatarShape) .clip(avatarShape)

View file

@ -57,14 +57,14 @@ fun ErrorDialogWithDoNotShowAgain(
Column { Column {
Text( Text(
text = content, text = content,
style = ElementTheme.materialTypography.bodyMedium, style = ElementTheme.typography.fontBodyMdRegular,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = doNotShowAgain, onCheckedChange = { doNotShowAgain = it }) Checkbox(checked = doNotShowAgain, onCheckedChange = { doNotShowAgain = it })
Text( Text(
text = stringResource(id = CommonStrings.common_do_not_show_this_again), text = stringResource(id = CommonStrings.common_do_not_show_this_again),
style = ElementTheme.materialTypography.bodyMedium, style = ElementTheme.typography.fontBodyMdRegular,
) )
} }
} }

View file

@ -76,7 +76,7 @@ fun TextFieldDialog(
item { item {
Text( Text(
text = content, text = content,
style = ElementTheme.materialTypography.bodyMedium, style = ElementTheme.typography.fontBodyMdRegular,
) )
} }
} }

View file

@ -65,7 +65,7 @@ internal fun SimpleAlertDialogContent(
content = { content = {
Text( Text(
text = content, text = content,
style = ElementTheme.materialTypography.bodyMedium, style = ElementTheme.typography.fontBodyMdRegular,
) )
}, },
submitText = submitText, submitText = submitText,

View file

@ -15,9 +15,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.KeyboardActionHandler
import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TextFieldLabelScope
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -135,6 +141,51 @@ fun FilledTextField(
) )
} }
@Composable
fun FilledTextField(
state: TextFieldState,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
label: @Composable (TextFieldLabelScope.() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
supportingText: @Composable (() -> Unit)? = null,
isError: Boolean = false,
inputTransformation: InputTransformation? = null,
outputTransformation: OutputTransformation? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActionHandler? = null,
lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default,
interactionSource: MutableInteractionSource? = null,
shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.colors()
) {
androidx.compose.material3.TextField(
state = state,
modifier = modifier,
enabled = enabled,
readOnly = readOnly,
textStyle = textStyle,
label = label,
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
supportingText = supportingText,
isError = isError,
inputTransformation = inputTransformation,
outputTransformation = outputTransformation,
keyboardOptions = keyboardOptions,
onKeyboardAction = keyboardActions,
lineLimits = lineLimits,
interactionSource = interactionSource,
shape = shape,
colors = colors,
)
}
@Preview(group = PreviewGroup.TextFields) @Preview(group = PreviewGroup.TextFields)
@Composable @Composable
internal fun FilledTextFieldLightPreview() = internal fun FilledTextFieldLightPreview() =

View file

@ -114,7 +114,7 @@ fun ListItem(
val decoratedHeadlineContent: @Composable () -> Unit = { val decoratedHeadlineContent: @Composable () -> Unit = {
CompositionLocalProvider( CompositionLocalProvider(
LocalTextStyle provides ElementTheme.materialTypography.bodyLarge, LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular,
LocalContentColor provides headlineColor, LocalContentColor provides headlineColor,
) { ) {
headlineContent() headlineContent()
@ -123,7 +123,7 @@ fun ListItem(
val decoratedSupportingContent: (@Composable () -> Unit)? = supportingContent?.let { content -> val decoratedSupportingContent: (@Composable () -> Unit)? = supportingContent?.let { content ->
{ {
CompositionLocalProvider( CompositionLocalProvider(
LocalTextStyle provides ElementTheme.materialTypography.bodyMedium, LocalTextStyle provides ElementTheme.typography.fontBodyMdRegular,
LocalContentColor provides supportingColor, LocalContentColor provides supportingColor,
) { ) {
content() content()

View file

@ -50,6 +50,6 @@ object ElementNavigationBarItemDefaults {
selectedTextColor = ElementTheme.colors.textPrimary, selectedTextColor = ElementTheme.colors.textPrimary,
unselectedIconColor = ElementTheme.colors.iconTertiary, unselectedIconColor = ElementTheme.colors.iconTertiary,
unselectedTextColor = ElementTheme.colors.textDisabled, unselectedTextColor = ElementTheme.colors.textDisabled,
selectedIndicatorColor = Color.Companion.Transparent, selectedIndicatorColor = Color.Transparent,
) )
} }

View file

@ -70,12 +70,6 @@ enum class FeatureFlags(
defaultValue = { false }, defaultValue = { false },
isFinished = false, isFinished = false,
), ),
Space(
key = "feature.space",
title = "Spaces",
defaultValue = { true },
isFinished = true,
),
SpaceSettings( SpaceSettings(
key = "feature.spaceSettings", key = "feature.spaceSettings",
title = "Space settings", title = "Space settings",

View file

@ -25,10 +25,14 @@ class DefaultFeatureFlagService(
private val featuresProvider: FeaturesProvider, private val featuresProvider: FeaturesProvider,
) : FeatureFlagService { ) : FeatureFlagService {
override fun isFeatureEnabledFlow(feature: Feature): Flow<Boolean> { override fun isFeatureEnabledFlow(feature: Feature): Flow<Boolean> {
return providers.filter { it.hasFeature(feature) } return if (feature.isFinished) {
.maxByOrNull(FeatureFlagProvider::priority) flowOf(feature.defaultValue(buildMeta))
?.isFeatureEnabledFlow(feature) } else {
?: flowOf(feature.defaultValue(buildMeta)) providers.filter { it.hasFeature(feature) }
.maxByOrNull(FeatureFlagProvider::priority)
?.isFeatureEnabledFlow(feature)
?: flowOf(feature.defaultValue(buildMeta))
}
} }
override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean { override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean {

View file

@ -177,4 +177,9 @@ interface JoinedRoom : BaseRoom {
* *
*/ */
suspend fun withdrawVerificationAndResend(userIds: List<UserId>, sendHandle: SendHandle): Result<Unit> suspend fun withdrawVerificationAndResend(userIds: List<UserId>, sendHandle: SendHandle): Result<Unit>
/**
* Subscribe to a [Flow] of [SendQueueUpdate] related to this room.
*/
fun subscribeToSendQueueUpdates(): Flow<SendQueueUpdate>
} }

View file

@ -0,0 +1,22 @@
/*
* 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.libraries.matrix.api.room
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.media.MediaSource
sealed interface SendQueueUpdate {
data class NewLocalEvent(val transactionId: TransactionId) : SendQueueUpdate
data class CancelledLocalEvent(val transactionId: TransactionId) : SendQueueUpdate
data class ReplacedLocalEvent(val transactionId: TransactionId) : SendQueueUpdate
data class SendError(val transactionId: TransactionId) : SendQueueUpdate
data class RetrySendingEvent(val transactionId: TransactionId) : SendQueueUpdate
data class SentEvent(val transactionId: TransactionId, val eventId: EventId) : SendQueueUpdate
data class MediaUpload(val relatedTo: EventId, val file: MediaSource?, val index: Long, val progress: Float) : SendQueueUpdate
}

View file

@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.room.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomNotificationSettingsState import io.element.android.libraries.matrix.api.room.RoomNotificationSettingsState
import io.element.android.libraries.matrix.api.room.SendQueueUpdate
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.knock.KnockRequest import io.element.android.libraries.matrix.api.room.knock.KnockRequest
@ -66,6 +67,8 @@ import org.matrix.rustcomponents.sdk.DateDividerMode
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
import org.matrix.rustcomponents.sdk.KnockRequestsListener import org.matrix.rustcomponents.sdk.KnockRequestsListener
import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType
import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate
import org.matrix.rustcomponents.sdk.SendQueueListener
import org.matrix.rustcomponents.sdk.TimelineConfiguration import org.matrix.rustcomponents.sdk.TimelineConfiguration
import org.matrix.rustcomponents.sdk.TimelineFilter import org.matrix.rustcomponents.sdk.TimelineFilter
import org.matrix.rustcomponents.sdk.TimelineFocus import org.matrix.rustcomponents.sdk.TimelineFocus
@ -486,6 +489,16 @@ class JoinedRustRoom(
} }
} }
override fun subscribeToSendQueueUpdates(): Flow<SendQueueUpdate> {
return mxCallbackFlow {
innerRoom.subscribeToSendQueueUpdates(object : SendQueueListener {
override fun onUpdate(update: RoomSendQueueUpdate) {
trySend(update.map())
}
})
}
}
override fun close() = destroy() override fun close() = destroy()
override fun destroy() { override fun destroy() {

View file

@ -0,0 +1,29 @@
/*
* 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.libraries.matrix.impl.room
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.room.SendQueueUpdate
import io.element.android.libraries.matrix.impl.media.map
import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate
fun RoomSendQueueUpdate.map(): SendQueueUpdate = when (this) {
is RoomSendQueueUpdate.NewLocalEvent -> SendQueueUpdate.NewLocalEvent(TransactionId(transactionId))
is RoomSendQueueUpdate.CancelledLocalEvent -> SendQueueUpdate.CancelledLocalEvent(TransactionId(transactionId))
is RoomSendQueueUpdate.MediaUpload -> SendQueueUpdate.MediaUpload(
relatedTo = EventId(relatedTo),
file = file?.map(),
index = index.toLong(),
progress = progress.current.toFloat() / progress.total.toFloat(),
)
is RoomSendQueueUpdate.ReplacedLocalEvent -> SendQueueUpdate.ReplacedLocalEvent(TransactionId(transactionId))
is RoomSendQueueUpdate.RetryEvent -> SendQueueUpdate.RetrySendingEvent(TransactionId(transactionId))
is RoomSendQueueUpdate.SendError -> SendQueueUpdate.SendError(TransactionId(transactionId))
is RoomSendQueueUpdate.SentEvent -> SendQueueUpdate.SentEvent(TransactionId(transactionId), EventId(eventId))
}

View file

@ -30,7 +30,7 @@ import org.matrix.rustcomponents.sdk.QueueWedgeError
import org.matrix.rustcomponents.sdk.Reaction import org.matrix.rustcomponents.sdk.Reaction
import org.matrix.rustcomponents.sdk.ShieldState import org.matrix.rustcomponents.sdk.ShieldState
import org.matrix.rustcomponents.sdk.TimelineItemContent import org.matrix.rustcomponents.sdk.TimelineItemContent
import uniffi.matrix_sdk_common.ShieldStateCode import uniffi.matrix_sdk_ui.TimelineEventShieldStateCode
import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState
import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo
@ -58,7 +58,7 @@ class EventTimelineItemMapper(
content = contentMapper.map(content), content = contentMapper.map(content),
origin = origin?.map(), origin = origin?.map(),
timelineItemDebugInfoProvider = { lazyProvider.debugInfo().map() }, timelineItemDebugInfoProvider = { lazyProvider.debugInfo().map() },
messageShieldProvider = { strict -> lazyProvider.getShields(strict)?.map() }, messageShieldProvider = { strict -> lazyProvider.getShields(strict).map() },
sendHandleProvider = { lazyProvider.getSendHandle()?.let(::RustSendHandle) } sendHandleProvider = { lazyProvider.getSendHandle()?.let(::RustSendHandle) }
) )
} }
@ -182,13 +182,13 @@ private fun ShieldState?.map(): MessageShield? {
is ShieldState.Red -> true is ShieldState.Red -> true
} }
return when (shieldStateCode) { return when (shieldStateCode) {
ShieldStateCode.AUTHENTICITY_NOT_GUARANTEED -> MessageShield.AuthenticityNotGuaranteed(isCritical) TimelineEventShieldStateCode.AUTHENTICITY_NOT_GUARANTEED -> MessageShield.AuthenticityNotGuaranteed(isCritical)
ShieldStateCode.UNKNOWN_DEVICE -> MessageShield.UnknownDevice(isCritical) TimelineEventShieldStateCode.UNKNOWN_DEVICE -> MessageShield.UnknownDevice(isCritical)
ShieldStateCode.UNSIGNED_DEVICE -> MessageShield.UnsignedDevice(isCritical) TimelineEventShieldStateCode.UNSIGNED_DEVICE -> MessageShield.UnsignedDevice(isCritical)
ShieldStateCode.UNVERIFIED_IDENTITY -> MessageShield.UnverifiedIdentity(isCritical) TimelineEventShieldStateCode.UNVERIFIED_IDENTITY -> MessageShield.UnverifiedIdentity(isCritical)
ShieldStateCode.SENT_IN_CLEAR -> MessageShield.SentInClear(isCritical) TimelineEventShieldStateCode.SENT_IN_CLEAR -> MessageShield.SentInClear(isCritical)
ShieldStateCode.VERIFICATION_VIOLATION -> MessageShield.VerificationViolation(isCritical) TimelineEventShieldStateCode.VERIFICATION_VIOLATION -> MessageShield.VerificationViolation(isCritical)
ShieldStateCode.MISMATCHED_SENDER -> MessageShield.MismatchedSender(isCritical) TimelineEventShieldStateCode.MISMATCHED_SENDER -> MessageShield.MismatchedSender(isCritical)
} }
} }

View file

@ -72,7 +72,8 @@ class RustSessionVerificationService(
// Listen for changes in verification status and update accordingly // Listen for changes in verification status and update accordingly
private val verificationStateListenerTaskHandle = encryptionService.verificationStateListener(object : VerificationStateListener { private val verificationStateListenerTaskHandle = encryptionService.verificationStateListener(object : VerificationStateListener {
override fun onUpdate(status: VerificationState) { override fun onUpdate(status: VerificationState) {
if (!isInitialized.get()) { // If the status is verified, just use it. It can't be a false positive like unknown or unverified
if (!isInitialized.get() && status != VerificationState.VERIFIED) {
Timber.d("Discarding new verifications state: $status. E2EE is not initialised yet") Timber.d("Discarding new verifications state: $status. E2EE is not initialised yet")
return return
} }

View file

@ -21,22 +21,24 @@ import org.matrix.rustcomponents.sdk.ShieldState
import org.matrix.rustcomponents.sdk.TimelineItemContent import org.matrix.rustcomponents.sdk.TimelineItemContent
import uniffi.matrix_sdk_ui.EventItemOrigin import uniffi.matrix_sdk_ui.EventItemOrigin
fun aRustEventTimelineItem( internal fun aRustEventTimelineItem(
isRemote: Boolean = true, isRemote: Boolean = true,
eventOrTransactionId: EventOrTransactionId = EventOrTransactionId.EventId(AN_EVENT_ID.value), eventOrTransactionId: EventOrTransactionId = EventOrTransactionId.EventId(AN_EVENT_ID.value),
sender: String = A_USER_ID.value, sender: String = A_USER_ID.value,
senderProfile: ProfileDetails = ProfileDetails.Unavailable, senderProfile: ProfileDetails = ProfileDetails.Unavailable,
isOwn: Boolean = true, isOwn: Boolean = true,
isEditable: Boolean = true, isEditable: Boolean = true,
content: TimelineItemContent = aRustTimelineItemMessageContent(), content: TimelineItemContent = aRustTimelineItemContentMsgLike(),
timestamp: ULong = 0uL, timestamp: ULong = 0uL,
debugInfo: EventTimelineItemDebugInfo = anEventTimelineItemDebugInfo(), debugInfo: EventTimelineItemDebugInfo = anEventTimelineItemDebugInfo(),
localSendState: EventSendState? = null, localSendState: EventSendState? = null,
readReceipts: Map<String, Receipt> = emptyMap(), readReceipts: Map<String, Receipt> = emptyMap(),
origin: EventItemOrigin? = EventItemOrigin.SYNC, origin: EventItemOrigin? = EventItemOrigin.SYNC,
canBeRepliedTo: Boolean = true, canBeRepliedTo: Boolean = true,
shieldsState: ShieldState? = null, shieldsState: ShieldState = ShieldState.None,
localCreatedAt: ULong? = null, localCreatedAt: ULong? = null,
forwarder: String? = null,
forwarderProfile: ProfileDetails? = null,
) = EventTimelineItem( ) = EventTimelineItem(
isRemote = isRemote, isRemote = isRemote,
eventOrTransactionId = eventOrTransactionId, eventOrTransactionId = eventOrTransactionId,
@ -54,5 +56,7 @@ fun aRustEventTimelineItem(
lazyProvider = FakeFfiLazyTimelineItemProvider( lazyProvider = FakeFfiLazyTimelineItemProvider(
debugInfo = debugInfo, debugInfo = debugInfo,
shieldsState = shieldsState, shieldsState = shieldsState,
) ),
forwarder = forwarder,
forwarderProfile = forwarderProfile,
) )

View file

@ -10,7 +10,7 @@ package io.element.android.libraries.matrix.impl.fixtures.factories
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo
fun anEventTimelineItemDebugInfo( internal fun anEventTimelineItemDebugInfo(
model: String = "model", model: String = "model",
originalJson: String? = null, originalJson: String? = null,
latestEditJson: String? = null, latestEditJson: String? = null,

View file

@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimelineEv
import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
import org.matrix.rustcomponents.sdk.Action import org.matrix.rustcomponents.sdk.Action
import org.matrix.rustcomponents.sdk.BatchNotificationResult
import org.matrix.rustcomponents.sdk.JoinRule import org.matrix.rustcomponents.sdk.JoinRule
import org.matrix.rustcomponents.sdk.NotificationEvent import org.matrix.rustcomponents.sdk.NotificationEvent
import org.matrix.rustcomponents.sdk.NotificationItem import org.matrix.rustcomponents.sdk.NotificationItem
@ -21,7 +22,7 @@ import org.matrix.rustcomponents.sdk.NotificationSenderInfo
import org.matrix.rustcomponents.sdk.NotificationStatus import org.matrix.rustcomponents.sdk.NotificationStatus
import org.matrix.rustcomponents.sdk.TimelineEvent import org.matrix.rustcomponents.sdk.TimelineEvent
fun aRustNotificationItem( internal fun aRustNotificationItem(
event: NotificationEvent = aRustNotificationEventTimeline(), event: NotificationEvent = aRustNotificationEventTimeline(),
senderInfo: NotificationSenderInfo = aRustNotificationSenderInfo(), senderInfo: NotificationSenderInfo = aRustNotificationSenderInfo(),
roomInfo: NotificationRoomInfo = aRustNotificationRoomInfo(), roomInfo: NotificationRoomInfo = aRustNotificationRoomInfo(),
@ -39,13 +40,13 @@ fun aRustNotificationItem(
actions = actions, actions = actions,
) )
fun aRustBatchNotificationResult( internal fun aRustBatchNotificationResultOk(
notificationStatus: NotificationStatus = NotificationStatus.Event(aRustNotificationItem()), notificationStatus: NotificationStatus = NotificationStatus.Event(aRustNotificationItem()),
) = org.matrix.rustcomponents.sdk.BatchNotificationResult.Ok( ) = BatchNotificationResult.Ok(
status = notificationStatus, status = notificationStatus,
) )
fun aRustNotificationSenderInfo( internal fun aRustNotificationSenderInfo(
displayName: String? = A_USER_NAME, displayName: String? = A_USER_NAME,
avatarUrl: String? = null, avatarUrl: String? = null,
isNameAmbiguous: Boolean = false, isNameAmbiguous: Boolean = false,
@ -55,7 +56,7 @@ fun aRustNotificationSenderInfo(
isNameAmbiguous = isNameAmbiguous, isNameAmbiguous = isNameAmbiguous,
) )
fun aRustNotificationRoomInfo( internal fun aRustNotificationRoomInfo(
displayName: String = A_ROOM_NAME, displayName: String = A_ROOM_NAME,
avatarUrl: String? = null, avatarUrl: String? = null,
canonicalAlias: String? = null, canonicalAlias: String? = null,
@ -77,7 +78,7 @@ fun aRustNotificationRoomInfo(
isSpace = isSpace, isSpace = isSpace,
) )
fun aRustNotificationEventTimeline( internal fun aRustNotificationEventTimeline(
event: TimelineEvent = FakeFfiTimelineEvent(), event: TimelineEvent = FakeFfiTimelineEvent(),
) = NotificationEvent.Timeline( ) = NotificationEvent.Timeline(
event = event, event = event,

View file

@ -22,15 +22,13 @@ internal fun aRustRoomDescription(
joinRule: PublicRoomJoinRule = PublicRoomJoinRule.PUBLIC, joinRule: PublicRoomJoinRule = PublicRoomJoinRule.PUBLIC,
isWorldReadable: Boolean = true, isWorldReadable: Boolean = true,
joinedMembers: ULong = 2u, joinedMembers: ULong = 2u,
): RoomDescription { ) = RoomDescription(
return RoomDescription( roomId = roomId,
roomId = roomId, name = name,
name = name, topic = topic,
topic = topic, alias = alias,
alias = alias, avatarUrl = avatarUrl,
avatarUrl = avatarUrl, joinRule = joinRule,
joinRule = joinRule, isWorldReadable = isWorldReadable,
isWorldReadable = isWorldReadable, joinedMembers = joinedMembers,
joinedMembers = joinedMembers, )
)
}

View file

@ -14,10 +14,8 @@ import org.matrix.rustcomponents.sdk.RoomHero
internal fun aRustRoomHero( internal fun aRustRoomHero(
userId: UserId = A_USER_ID, userId: UserId = A_USER_ID,
): RoomHero { ) = RoomHero(
return RoomHero( userId = userId.value,
userId = userId.value, displayName = "displayName",
displayName = "displayName", avatarUrl = "avatarUrl",
avatarUrl = "avatarUrl", )
)
}

View file

@ -22,7 +22,7 @@ import org.matrix.rustcomponents.sdk.RoomPowerLevels
import org.matrix.rustcomponents.sdk.SuccessorRoom import org.matrix.rustcomponents.sdk.SuccessorRoom
import uniffi.matrix_sdk_base.EncryptionState import uniffi.matrix_sdk_base.EncryptionState
fun aRustRoomInfo( internal fun aRustRoomInfo(
id: String = A_ROOM_ID.value, id: String = A_ROOM_ID.value,
displayName: String? = A_ROOM_NAME, displayName: String? = A_ROOM_NAME,
rawName: String? = A_ROOM_NAME, rawName: String? = A_ROOM_NAME,

View file

@ -14,7 +14,7 @@ import org.matrix.rustcomponents.sdk.PowerLevel
import org.matrix.rustcomponents.sdk.RoomMember import org.matrix.rustcomponents.sdk.RoomMember
import uniffi.matrix_sdk.RoomMemberRole import uniffi.matrix_sdk.RoomMemberRole
fun aRustRoomMember( internal fun aRustRoomMember(
userId: UserId, userId: UserId,
displayName: String? = null, displayName: String? = null,
avatarUrl: String? = null, avatarUrl: String? = null,

View file

@ -11,7 +11,7 @@ package io.element.android.libraries.matrix.impl.fixtures.factories
import org.matrix.rustcomponents.sdk.RoomNotificationMode import org.matrix.rustcomponents.sdk.RoomNotificationMode
import org.matrix.rustcomponents.sdk.RoomNotificationSettings import org.matrix.rustcomponents.sdk.RoomNotificationSettings
fun aRustRoomNotificationSettings( internal fun aRustRoomNotificationSettings(
mode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES, mode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES,
isDefault: Boolean = true, isDefault: Boolean = true,
) = RoomNotificationSettings( ) = RoomNotificationSettings(

View file

@ -19,20 +19,18 @@ internal fun aRustRoomPreviewInfo(
canonicalAlias: String? = A_ROOM_ALIAS.value, canonicalAlias: String? = A_ROOM_ALIAS.value,
membership: Membership? = Membership.JOINED, membership: Membership? = Membership.JOINED,
joinRule: JoinRule = JoinRule.Public, joinRule: JoinRule = JoinRule.Public,
): RoomPreviewInfo { ) = RoomPreviewInfo(
return RoomPreviewInfo( roomId = A_ROOM_ID.value,
roomId = A_ROOM_ID.value, canonicalAlias = canonicalAlias,
canonicalAlias = canonicalAlias, name = "name",
name = "name", topic = "topic",
topic = "topic", avatarUrl = "avatarUrl",
avatarUrl = "avatarUrl", numJoinedMembers = 1u,
numJoinedMembers = 1u, numActiveMembers = 1u,
numActiveMembers = 1u, isDirect = false,
isDirect = false, roomType = RoomType.Room,
roomType = RoomType.Room, isHistoryWorldReadable = true,
isHistoryWorldReadable = true, membership = membership,
membership = membership, joinRule = joinRule,
joinRule = joinRule, heroes = null,
heroes = null, )
)
}

View file

@ -18,14 +18,12 @@ internal fun aRustSession(
proxy: SlidingSyncVersion = SlidingSyncVersion.NONE, proxy: SlidingSyncVersion = SlidingSyncVersion.NONE,
accessToken: String = "accessToken", accessToken: String = "accessToken",
refreshToken: String = "refreshToken", refreshToken: String = "refreshToken",
): Session { ) = Session(
return Session( accessToken = accessToken,
accessToken = accessToken, refreshToken = refreshToken,
refreshToken = refreshToken, userId = A_USER_ID.value,
userId = A_USER_ID.value, deviceId = A_DEVICE_ID.value,
deviceId = A_DEVICE_ID.value, homeserverUrl = A_HOMESERVER_URL,
homeserverUrl = A_HOMESERVER_URL, oidcData = null,
oidcData = null, slidingSyncVersion = proxy,
slidingSyncVersion = proxy, )
)
}

View file

@ -16,7 +16,7 @@ import org.matrix.rustcomponents.sdk.RoomHero
import org.matrix.rustcomponents.sdk.RoomType import org.matrix.rustcomponents.sdk.RoomType
import org.matrix.rustcomponents.sdk.SpaceRoom import org.matrix.rustcomponents.sdk.SpaceRoom
fun aRustSpaceRoom( internal fun aRustSpaceRoom(
roomId: RoomId = A_ROOM_ID, roomId: RoomId = A_ROOM_ID,
isDirect: Boolean = false, isDirect: Boolean = false,
canonicalAlias: String? = null, canonicalAlias: String? = null,

View file

@ -15,15 +15,13 @@ import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.TextMessageContent import org.matrix.rustcomponents.sdk.TextMessageContent
import org.matrix.rustcomponents.sdk.TimelineEventContent import org.matrix.rustcomponents.sdk.TimelineEventContent
fun aRustTimelineEventContentMessageLike( internal fun aRustTimelineEventContentMessageLike(
content: MessageLikeEventContent = aRustMessageLikeEventContentRoomMessage(), content: MessageLikeEventContent = aRustMessageLikeEventContentRoomMessage(),
): TimelineEventContent.MessageLike { ) = TimelineEventContent.MessageLike(
return TimelineEventContent.MessageLike( content = content,
content = content, )
)
}
fun aRustMessageLikeEventContentRoomMessage( internal fun aRustMessageLikeEventContentRoomMessage(
messageType: MessageType = aRustMessageTypeText(), messageType: MessageType = aRustMessageTypeText(),
inReplyToEventId: String? = null, inReplyToEventId: String? = null,
) = MessageLikeEventContent.RoomMessage( ) = MessageLikeEventContent.RoomMessage(
@ -31,13 +29,13 @@ fun aRustMessageLikeEventContentRoomMessage(
inReplyToEventId = inReplyToEventId, inReplyToEventId = inReplyToEventId,
) )
fun aRustMessageTypeText( internal fun aRustMessageTypeText(
content: TextMessageContent = aRustTextMessageContent(), content: TextMessageContent = aRustTextMessageContent(),
) = MessageType.Text( ) = MessageType.Text(
content = content, content = content,
) )
fun aRustTextMessageContent( internal fun aRustTextMessageContent(
body: String = A_MESSAGE, body: String = A_MESSAGE,
formatted: FormattedBody? = null, formatted: FormattedBody? = null,
) = TextMessageContent( ) = TextMessageContent(

View file

@ -15,7 +15,9 @@ import org.matrix.rustcomponents.sdk.MsgLikeKind
import org.matrix.rustcomponents.sdk.TextMessageContent import org.matrix.rustcomponents.sdk.TextMessageContent
import org.matrix.rustcomponents.sdk.TimelineItemContent import org.matrix.rustcomponents.sdk.TimelineItemContent
fun aRustTimelineItemMessageContent(body: String = "Hello") = TimelineItemContent.MsgLike( internal fun aRustTimelineItemContentMsgLike(
body: String = "Hello",
) = TimelineItemContent.MsgLike(
content = MsgLikeContent( content = MsgLikeContent(
kind = MsgLikeKind.Message( kind = MsgLikeKind.Message(
content = MessageContent( content = MessageContent(

View file

@ -19,14 +19,12 @@ internal fun aRustUnableToDecryptInfo(
userTrustsOwnIdentity: Boolean = false, userTrustsOwnIdentity: Boolean = false,
senderHomeserver: String = "", senderHomeserver: String = "",
ownHomeserver: String = "", ownHomeserver: String = "",
): UnableToDecryptInfo { ) = UnableToDecryptInfo(
return UnableToDecryptInfo( eventId = eventId,
eventId = eventId, timeToDecryptMs = timeToDecryptMs,
timeToDecryptMs = timeToDecryptMs, cause = cause,
cause = cause, eventLocalAgeMillis = eventLocalAgeMillis,
eventLocalAgeMillis = eventLocalAgeMillis, userTrustsOwnIdentity = userTrustsOwnIdentity,
userTrustsOwnIdentity = userTrustsOwnIdentity, senderHomeserver = senderHomeserver,
senderHomeserver = senderHomeserver, ownHomeserver = ownHomeserver,
ownHomeserver = ownHomeserver, )
)
}

View file

@ -11,7 +11,7 @@ package io.element.android.libraries.matrix.impl.fixtures.factories
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import org.matrix.rustcomponents.sdk.UserProfile import org.matrix.rustcomponents.sdk.UserProfile
fun aRustUserProfile( internal fun aRustUserProfile(
userId: String = A_USER_ID.value, userId: String = A_USER_ID.value,
displayName: String = "displayName", displayName: String = "displayName",
avatarUrl: String = "avatarUrl", avatarUrl: String = "avatarUrl",

View file

@ -17,7 +17,7 @@ import org.matrix.rustcomponents.sdk.ShieldState
class FakeFfiLazyTimelineItemProvider( class FakeFfiLazyTimelineItemProvider(
private val debugInfo: EventTimelineItemDebugInfo = anEventTimelineItemDebugInfo(), private val debugInfo: EventTimelineItemDebugInfo = anEventTimelineItemDebugInfo(),
private val shieldsState: ShieldState? = null, private val shieldsState: ShieldState = ShieldState.None,
) : LazyTimelineItemProvider(NoHandle) { ) : LazyTimelineItemProvider(NoHandle) {
override fun getShields(strict: Boolean) = shieldsState override fun getShields(strict: Boolean) = shieldsState
override fun debugInfo() = debugInfo override fun debugInfo() = debugInfo

View file

@ -12,7 +12,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.exception.NotificationResolverException import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.matrix.api.notification.NotificationContent import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustBatchNotificationResult import io.element.android.libraries.matrix.impl.fixtures.factories.aRustBatchNotificationResultOk
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustNotificationEventTimeline import io.element.android.libraries.matrix.impl.fixtures.factories.aRustNotificationEventTimeline
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustNotificationItem import io.element.android.libraries.matrix.impl.fixtures.factories.aRustNotificationItem
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiNotificationClient import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiNotificationClient
@ -38,7 +38,7 @@ class RustNotificationServiceTest {
@Test @Test
fun test() = runTest { fun test() = runTest {
val notificationClient = FakeFfiNotificationClient( val notificationClient = FakeFfiNotificationClient(
notificationItemResult = mapOf(AN_EVENT_ID.value to aRustBatchNotificationResult()), notificationItemResult = mapOf(AN_EVENT_ID.value to aRustBatchNotificationResultOk()),
) )
val sut = createRustNotificationService( val sut = createRustNotificationService(
notificationClient = notificationClient, notificationClient = notificationClient,
@ -66,10 +66,10 @@ class RustNotificationServiceTest {
} }
val notificationClient = FakeFfiNotificationClient( val notificationClient = FakeFfiNotificationClient(
notificationItemResult = mapOf( notificationItemResult = mapOf(
AN_EVENT_ID.value to aRustBatchNotificationResult( AN_EVENT_ID.value to aRustBatchNotificationResultOk(
notificationStatus = NotificationStatus.Event(aRustNotificationItem(aRustNotificationEventTimeline(faultyEvent))) notificationStatus = NotificationStatus.Event(aRustNotificationItem(aRustNotificationEventTimeline(faultyEvent)))
), ),
AN_EVENT_ID_2.value to aRustBatchNotificationResult() AN_EVENT_ID_2.value to aRustBatchNotificationResultOk()
), ),
) )
val sut = createRustNotificationService( val sut = createRustNotificationService(

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomNotificationSettingsState import io.element.android.libraries.matrix.api.room.RoomNotificationSettingsState
import io.element.android.libraries.matrix.api.room.SendQueueUpdate
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.knock.KnockRequest import io.element.android.libraries.matrix.api.room.knock.KnockRequest
@ -39,6 +40,7 @@ import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -83,6 +85,8 @@ class FakeJoinedRoom(
private val updateJoinRuleResult: (JoinRule) -> Result<Unit> = { lambdaError() }, private val updateJoinRuleResult: (JoinRule) -> Result<Unit> = { lambdaError() },
private val setSendQueueEnabledResult: (Boolean) -> Unit = { _: Boolean -> }, private val setSendQueueEnabledResult: (Boolean) -> Unit = { _: Boolean -> },
) : JoinedRoom, BaseRoom by baseRoom { ) : JoinedRoom, BaseRoom by baseRoom {
private val sendQueueUpdates = MutableSharedFlow<SendQueueUpdate>(extraBufferCapacity = 10)
fun givenRoomMembersState(state: RoomMembersState) { fun givenRoomMembersState(state: RoomMembersState) {
baseRoom.givenRoomMembersState(state) baseRoom.givenRoomMembersState(state)
} }
@ -219,6 +223,10 @@ class FakeJoinedRoom(
withdrawVerificationAndResendResult(userIds, sendHandle) withdrawVerificationAndResendResult(userIds, sendHandle)
} }
override fun subscribeToSendQueueUpdates(): Flow<SendQueueUpdate> {
return sendQueueUpdates
}
private suspend fun simulateSendMediaProgress(progressCallback: ProgressCallback?) { private suspend fun simulateSendMediaProgress(progressCallback: ProgressCallback?) {
progressCallbackValues.forEach { (current, total) -> progressCallbackValues.forEach { (current, total) ->
progressCallback?.onProgress(current, total) progressCallback?.onProgress(current, total)
@ -229,4 +237,8 @@ class FakeJoinedRoom(
fun emitSyncUpdate() { fun emitSyncUpdate() {
(syncUpdateFlow as MutableStateFlow).value = syncUpdateFlow.value + 1 (syncUpdateFlow as MutableStateFlow).value = syncUpdateFlow.value + 1
} }
suspend fun givenSendQueueUpdate(sendQueueUpdate: SendQueueUpdate) {
sendQueueUpdates.emit(sendQueueUpdate)
}
} }

View file

@ -36,7 +36,7 @@ dependencies {
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.jsoup) implementation(libs.matrix.richtexteditor)
implementation(projects.libraries.previewutils) implementation(projects.libraries.previewutils)
testCommonDependencies(libs, true) testCommonDependencies(libs, true)

View file

@ -12,7 +12,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import org.jsoup.Jsoup import io.element.android.wysiwyg.utils.HtmlToDomParser
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
/** /**
@ -34,9 +34,9 @@ fun FormattedBody.toHtmlDocument(
?.trimEnd() ?.trimEnd()
?.let { formattedBody -> ?.let { formattedBody ->
val dom = if (prefix != null) { val dom = if (prefix != null) {
Jsoup.parse("$prefix $formattedBody") HtmlToDomParser.document("$prefix $formattedBody")
} else { } else {
Jsoup.parse(formattedBody) HtmlToDomParser.document(formattedBody)
} }
// Prepend `@` to mentions // Prepend `@` to mentions

View file

@ -55,8 +55,15 @@ private class PlainTextNodeVisitor : NodeVisitor {
private val builder = StringBuilder() private val builder = StringBuilder()
override fun head(node: Node, depth: Int) { override fun head(node: Node, depth: Int) {
if (node is TextNode && node.text().isNotBlank()) { if (node is TextNode) {
builder.append(node.text()) // If the text node is blank, only add a single whitespace char if there wasn't already one
if (node.text().isBlank()) {
if (builder.lastOrNull()?.isWhitespace() == false) {
builder.append(" ")
}
} else {
builder.append(node.text())
}
} else if (node is Element && node.tagName() == "li") { } else if (node is Element && node.tagName() == "li") {
val index = node.elementSiblingIndex() + 1 val index = node.elementSiblingIndex() + 1
val isOrdered = node.parent()?.nodeName()?.lowercase() == "ol" val isOrdered = node.parent()?.nodeName()?.lowercase() == "ol"

View file

@ -45,7 +45,7 @@ class ToPlainTextTest {
val formattedBody = FormattedBody( val formattedBody = FormattedBody(
format = MessageFormat.HTML, format = MessageFormat.HTML,
body = """ body = """
Hello world Hello <strong>formatted</strong> <em>world</em>
<ul><li>This is an unordered list.</li></ul> <ul><li>This is an unordered list.</li></ul>
<ol><li>This is an ordered list.</li></ol> <ol><li>This is an ordered list.</li></ol>
<br /> <br />
@ -53,7 +53,7 @@ class ToPlainTextTest {
) )
assertThat(formattedBody.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo( assertThat(formattedBody.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo(
""" """
Hello world Hello formatted world
This is an unordered list. This is an unordered list.
1. This is an ordered list. 1. This is an ordered list.
""".trimIndent() """.trimIndent()

View file

@ -47,6 +47,12 @@ interface MediaPlayer : AutoCloseable {
*/ */
fun seekTo(positionMs: Long) fun seekTo(positionMs: Long)
/**
* Sets the playback speed.
* @param speed The playback speed (e.g., 0.5f for half speed, 1.0f for normal, 2.0f for double speed)
*/
fun setPlaybackSpeed(speed: Float)
/** /**
* Releases any resources associated with this player. * Releases any resources associated with this player.
*/ */

View file

@ -159,6 +159,10 @@ class DefaultMediaPlayer(
} }
} }
override fun setPlaybackSpeed(speed: Float) {
player.setPlaybackSpeed(speed)
}
override fun close() { override fun close() {
player.release() player.release()
} }

View file

@ -34,6 +34,7 @@ interface SimplePlayer {
fun isPlaying(): Boolean fun isPlaying(): Boolean
fun pause() fun pause()
fun seekTo(positionMs: Long) fun seekTo(positionMs: Long)
fun setPlaybackSpeed(speed: Float)
fun release() fun release()
interface Listener { interface Listener {
fun onIsPlayingChanged(isPlaying: Boolean) fun onIsPlayingChanged(isPlaying: Boolean)
@ -88,5 +89,9 @@ class DefaultSimplePlayer(
override fun seekTo(positionMs: Long) = p.seekTo(positionMs) override fun seekTo(positionMs: Long) = p.seekTo(positionMs)
override fun setPlaybackSpeed(speed: Float) {
p.setPlaybackParameters(p.playbackParameters.withSpeed(speed))
}
override fun release() = p.release() override fun release() = p.release()
} }

View file

@ -20,6 +20,7 @@ class FakeSimplePlayer(
private val isPlayingLambda: () -> Boolean = { lambdaError() }, private val isPlayingLambda: () -> Boolean = { lambdaError() },
private val pauseLambda: () -> Unit = { lambdaError() }, private val pauseLambda: () -> Unit = { lambdaError() },
private val seekToLambda: (Long) -> Unit = { lambdaError() }, private val seekToLambda: (Long) -> Unit = { lambdaError() },
private val setPlaybackSpeedLambda: (Float) -> Unit = { lambdaError() },
private val releaseLambda: () -> Unit = { lambdaError() }, private val releaseLambda: () -> Unit = { lambdaError() },
) : SimplePlayer { ) : SimplePlayer {
private val listeners = mutableListOf<SimplePlayer.Listener>() private val listeners = mutableListOf<SimplePlayer.Listener>()
@ -45,6 +46,7 @@ class FakeSimplePlayer(
override fun isPlaying() = isPlayingLambda() override fun isPlaying() = isPlayingLambda()
override fun pause() = pauseLambda() override fun pause() = pauseLambda()
override fun seekTo(positionMs: Long) = seekToLambda(positionMs) override fun seekTo(positionMs: Long) = seekToLambda(positionMs)
override fun setPlaybackSpeed(speed: Float) = setPlaybackSpeedLambda(speed)
override fun release() = releaseLambda() override fun release() = releaseLambda()
fun simulateIsPlayingChanged(isPlaying: Boolean) { fun simulateIsPlayingChanged(isPlaying: Boolean) {

View file

@ -96,6 +96,10 @@ class FakeMediaPlayer(
} }
} }
override fun setPlaybackSpeed(speed: Float) {
// no-op
}
override fun close() { override fun close() {
// no-op // no-op
} }

View file

@ -11,6 +11,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -38,6 +39,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.atoms.PlaybackSpeedButton
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -50,7 +52,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.impl.model.MediaItem import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
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.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
@ -92,7 +94,7 @@ private fun VoiceInfoRow(
onLongClick: () -> Unit, onLongClick: () -> Unit,
) { ) {
fun playPause() { fun playPause() {
state.eventSink(VoiceMessageEvents.PlayPause) state.eventSink(VoiceMessageEvent.PlayPause)
} }
Row( Row(
@ -112,21 +114,30 @@ private fun VoiceInfoRow(
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp), .padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
when (state.button) { when (state.buttonType) {
VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause) VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause) VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.Button.Downloading -> ProgressButton() VoiceMessageState.ButtonType.Downloading -> ProgressButton()
VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause) VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false) VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( Column(
text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time, horizontalAlignment = Alignment.CenterHorizontally,
color = ElementTheme.colors.textSecondary, verticalArrangement = Arrangement.spacedBy(2.dp),
style = ElementTheme.typography.fontBodyMdMedium, ) {
maxLines = 1, PlaybackSpeedButton(
overflow = TextOverflow.Ellipsis, speed = state.playbackSpeed,
) onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) },
)
Text(
text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
WaveformPlaybackView( WaveformPlaybackView(
modifier = Modifier modifier = Modifier
@ -136,7 +147,7 @@ private fun VoiceInfoRow(
playbackProgress = state.progress, playbackProgress = state.progress,
waveform = voice.mediaInfo.waveform.orEmpty().toImmutableList(), waveform = voice.mediaInfo.waveform.orEmpty().toImmutableList(),
onSeek = { onSeek = {
state.eventSink(VoiceMessageEvents.Seek(it)) state.eventSink(VoiceMessageEvent.Seek(it))
}, },
seekEnabled = true, seekEnabled = true,
) )

View file

@ -75,7 +75,7 @@ class DefaultRoomGroupMessageCreator(
hasSmartReplyError = smartReplyErrors.isNotEmpty(), hasSmartReplyError = smartReplyErrors.isNotEmpty(),
shouldBing = events.any { it.noisy }, shouldBing = events.any { it.noisy },
customSound = events.last().soundName, customSound = events.last().soundName,
isUpdated = events.last().isUpdated, isUpdated = events.last().let { it.isUpdated || it.outGoingMessage },
), ),
threadId = threadId, threadId = threadId,
largeIcon = largeBitmap, largeIcon = largeBitmap,

View file

@ -38,6 +38,8 @@
<string name="notification_room_invite_body_with_sender">"%1$s vous a invité à rejoindre le salon"</string> <string name="notification_room_invite_body_with_sender">"%1$s vous a invité à rejoindre le salon"</string>
<string name="notification_sender_me">"Moi"</string> <string name="notification_sender_me">"Moi"</string>
<string name="notification_sender_mention_reply">"%1$s mentionné ou en réponse"</string> <string name="notification_sender_mention_reply">"%1$s mentionné ou en réponse"</string>
<string name="notification_space_invite_body">"Vous a invité à rejoindre lespace"</string>
<string name="notification_space_invite_body_with_sender">"%1$s vous a invité à rejoindre lespace"</string>
<string name="notification_test_push_notification_content">"Vous êtes en train de voir la notification ! Cliquez-moi !"</string> <string name="notification_test_push_notification_content">"Vous êtes en train de voir la notification ! Cliquez-moi !"</string>
<string name="notification_thread_in_room">"Discussion dans %1$s"</string> <string name="notification_thread_in_room">"Discussion dans %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s : %2$s"</string> <string name="notification_ticker_text_dm">"%1$s : %2$s"</string>

View file

@ -161,6 +161,7 @@
<string name="action_static_map_load">"Cliquez pour charger la carte"</string> <string name="action_static_map_load">"Cliquez pour charger la carte"</string>
<string name="action_take_photo">"Prendre une photo"</string> <string name="action_take_photo">"Prendre une photo"</string>
<string name="action_tap_for_options">"Appuyez pour afficher les options"</string> <string name="action_tap_for_options">"Appuyez pour afficher les options"</string>
<string name="action_translate">"Traduire"</string>
<string name="action_try_again">"Essayer à nouveau"</string> <string name="action_try_again">"Essayer à nouveau"</string>
<string name="action_unpin">"Désépingler"</string> <string name="action_unpin">"Désépingler"</string>
<string name="action_view">"Voir"</string> <string name="action_view">"Voir"</string>
@ -235,6 +236,7 @@ Raison : %1$s."</string>
<string name="common_light">"Clair"</string> <string name="common_light">"Clair"</string>
<string name="common_line_copied_to_clipboard">"Ligne copiée dans le presse-papiers"</string> <string name="common_line_copied_to_clipboard">"Ligne copiée dans le presse-papiers"</string>
<string name="common_link_copied_to_clipboard">"Lien copié dans le presse-papiers"</string> <string name="common_link_copied_to_clipboard">"Lien copié dans le presse-papiers"</string>
<string name="common_link_new_device">"Associer un nouvel appareil"</string>
<string name="common_loading">"Chargement…"</string> <string name="common_loading">"Chargement…"</string>
<string name="common_loading_more">"Chargement…"</string> <string name="common_loading_more">"Chargement…"</string>
<plurals name="common_many_members"> <plurals name="common_many_members">
@ -252,6 +254,7 @@ Raison : %1$s."</string>
<string name="common_message_removed">"Message supprimé"</string> <string name="common_message_removed">"Message supprimé"</string>
<string name="common_modern">"Moderne"</string> <string name="common_modern">"Moderne"</string>
<string name="common_mute">"Mettre en sourdine"</string> <string name="common_mute">"Mettre en sourdine"</string>
<string name="common_name">"Nom"</string>
<string name="common_name_and_id">"%1$s (%2$s)"</string> <string name="common_name_and_id">"%1$s (%2$s)"</string>
<string name="common_no_results">"Aucun résultat"</string> <string name="common_no_results">"Aucun résultat"</string>
<string name="common_no_room_name">"Salon sans nom"</string> <string name="common_no_room_name">"Salon sans nom"</string>
@ -326,6 +329,7 @@ Raison : %1$s."</string>
<string name="common_something_went_wrong">"Une erreur sest produite"</string> <string name="common_something_went_wrong">"Une erreur sest produite"</string>
<string name="common_something_went_wrong_message">"Nous avons rencontré un problème. Veuillez réessayer."</string> <string name="common_something_went_wrong_message">"Nous avons rencontré un problème. Veuillez réessayer."</string>
<string name="common_space">"Espace"</string> <string name="common_space">"Espace"</string>
<string name="common_space_topic_placeholder">"Quel est le sujet de cet espace ?"</string>
<plurals name="common_spaces"> <plurals name="common_spaces">
<item quantity="one">"%1$d Espace"</item> <item quantity="one">"%1$d Espace"</item>
<item quantity="other">"%1$d Espaces"</item> <item quantity="other">"%1$d Espaces"</item>
@ -370,7 +374,7 @@ Raison : %1$s."</string>
<string name="common_waiting">"En attente…"</string> <string name="common_waiting">"En attente…"</string>
<string name="common_waiting_for_decryption_key">"En attente de la clé de déchiffrement"</string> <string name="common_waiting_for_decryption_key">"En attente de la clé de déchiffrement"</string>
<string name="common_you">"Vous"</string> <string name="common_you">"Vous"</string>
<string name="crypto_history_visible">"Les messages que vous enverrez seront partagés avec les nouveaux membres invités dans ce salon. %1$s"</string> <string name="crypto_history_visible">"Ce salon a été configuré pour que les nouveaux membres puissent lire lhistorique. %1$s"</string>
<string name="crypto_identity_change_pin_violation">"Lidentité de %1$s a été réinitialisée. %2$s"</string> <string name="crypto_identity_change_pin_violation">"Lidentité de %1$s a été réinitialisée. %2$s"</string>
<string name="crypto_identity_change_pin_violation_new">"Lidentité de %1$s %2$s a été réinitialisée. %3$s"</string> <string name="crypto_identity_change_pin_violation_new">"Lidentité de %1$s %2$s a été réinitialisée. %3$s"</string>
<string name="crypto_identity_change_pin_violation_new_user_id">"(%1$s)"</string> <string name="crypto_identity_change_pin_violation_new_user_id">"(%1$s)"</string>

View file

@ -11,7 +11,7 @@
</plurals> </plurals>
<string name="a11y_edit_avatar">"Uredi avatar"</string> <string name="a11y_edit_avatar">"Uredi avatar"</string>
<string name="a11y_edit_room_address_hint">"Potpuna adresa bit će %1$s"</string> <string name="a11y_edit_room_address_hint">"Potpuna adresa bit će %1$s"</string>
<string name="a11y_encryption_details">"Pojedinosti šifriranja"</string> <string name="a11y_encryption_details">"Pojedinosti o šifriranju"</string>
<string name="a11y_expand_message_text_field">"Proširi tekstno polje poruke"</string> <string name="a11y_expand_message_text_field">"Proširi tekstno polje poruke"</string>
<string name="a11y_hide_password">"Sakrij zaporku"</string> <string name="a11y_hide_password">"Sakrij zaporku"</string>
<string name="a11y_join_call">"Pridruži se pozivu"</string> <string name="a11y_join_call">"Pridruži se pozivu"</string>
@ -58,7 +58,7 @@
<string name="a11y_your_avatar">"Vaš avatar"</string> <string name="a11y_your_avatar">"Vaš avatar"</string>
<string name="action_accept">"Prihvati"</string> <string name="action_accept">"Prihvati"</string>
<string name="action_add_caption">"Dodaj opis"</string> <string name="action_add_caption">"Dodaj opis"</string>
<string name="action_add_to_timeline">"Dodaj na vremensku crtu"</string> <string name="action_add_to_timeline">"Dodaj na vremensku traku"</string>
<string name="action_back">"Natrag"</string> <string name="action_back">"Natrag"</string>
<string name="action_call">"Poziv"</string> <string name="action_call">"Poziv"</string>
<string name="action_cancel">"Odustani"</string> <string name="action_cancel">"Odustani"</string>
@ -163,6 +163,7 @@
<string name="action_static_map_load">"Dodirnite za učitavanje karte"</string> <string name="action_static_map_load">"Dodirnite za učitavanje karte"</string>
<string name="action_take_photo">"Uslikaj"</string> <string name="action_take_photo">"Uslikaj"</string>
<string name="action_tap_for_options">"Dodirnite za mogućnosti"</string> <string name="action_tap_for_options">"Dodirnite za mogućnosti"</string>
<string name="action_translate">"Prevedi"</string>
<string name="action_try_again">"Pokušajte ponovno"</string> <string name="action_try_again">"Pokušajte ponovno"</string>
<string name="action_unpin">"Otkvači"</string> <string name="action_unpin">"Otkvači"</string>
<string name="action_view">"Prikaz"</string> <string name="action_view">"Prikaz"</string>

View file

@ -8,7 +8,8 @@
package io.element.android.libraries.voiceplayer.api package io.element.android.libraries.voiceplayer.api
sealed interface VoiceMessageEvents { sealed interface VoiceMessageEvent {
data object PlayPause : VoiceMessageEvents data object PlayPause : VoiceMessageEvent
data class Seek(val percentage: Float) : VoiceMessageEvents data class Seek(val percentage: Float) : VoiceMessageEvent
data object ChangePlaybackSpeed : VoiceMessageEvent
} }

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