Merge branch 'develop' into feature/fga/space_members_access

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

View File

@@ -66,10 +66,10 @@
-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
-keep class org.matrix.rustcomponents.sdk.** { *;}
-keep class uniffi.** { *;}
-keep class io.element.android.x.di.** { *; }
-keep,allowshrinking class org.matrix.rustcomponents.sdk.** { *;}
-keep,allowshrinking class uniffi.** { *;}
-keep,allowshrinking class io.element.android.x.di.** { *; }
-keepclasseswithmembernames,allowoptimization,allowshrinking class io.element.android.** { *; }
# 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.pushproviders.test)
testImplementation(projects.features.forward.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.rageshake.test)
testImplementation(projects.services.appnavstate.impl)

View File

@@ -320,7 +320,7 @@ class RootFlowNode(
is ResolvedIntent.Navigation -> {
val openingRoomFromNotification = intent.getBooleanExtra(ROOM_OPENED_FROM_NOTIFICATION, false)
if (openingRoomFromNotification && resolvedIntent.deeplinkData is DeeplinkData.Room) {
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.NotificationTapOpensTimeline)
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.NotificationToMessage)
}
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.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.services.analytics.api.watchers.AnalyticsSendMessageWatcher
interface TimelineBindings {
val timelineProvider: TimelineProvider
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.ui.room.LoadingRoomState
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.AnalyticsService
import kotlinx.coroutines.flow.SharingStarted
@@ -128,7 +128,7 @@ class RoomFlowNode(
override fun onBuilt() {
super.onBuilt()
val parentTransaction = analyticsService.getLongRunningTransaction(NotificationTapOpensTimeline)
val parentTransaction = analyticsService.getLongRunningTransaction(NotificationToMessage)
val openRoomTransaction = analyticsService.startLongRunningTransaction(OpenRoom, parentTransaction)
analyticsService.startLongRunningTransaction(LoadJoinedRoomFlow, openRoomTransaction)
resolveRoomId()

View File

@@ -98,6 +98,8 @@ class JoinedRoomLoadedFlowNode(
private val callback: Callback = callback()
override val graph = roomGraphFactory.create(inputs.room)
private val sendMessageWatcher = (graph as? TimelineBindings)?.analyticsSendMessageWatcher
// This is an ugly hack to check activity recreation
private var currentActivity: Activity? = null
@@ -109,6 +111,7 @@ class JoinedRoomLoadedFlowNode(
Timber.v("OnCreate => ${inputs.room.roomId}")
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
activeRoomsHolder.addRoom(inputs.room)
sendMessageWatcher?.start()
fetchRoomMembers()
trackVisitedRoom()
},
@@ -120,6 +123,7 @@ class JoinedRoomLoadedFlowNode(
},
onDestroy = {
Timber.v("OnDestroy")
sendMessageWatcher?.stop()
// 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
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.google.common.truth.Truth.assertThat
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.joined.FakeJoinedRoomLoadedFlowNodeCallback
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.forward.test.FakeForwardEntryPoint
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.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.childNode
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.FakeMatrixClient
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.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.watchers.FakeAnalyticsSendMessageWatcher
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
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 {
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(IntoMap::class)
.addAnnotation(
AnnotationSpec.Companion.builder(ClassName.bestGuess(nodeKeyFqName.asString())).addMember(
AnnotationSpec.builder(ClassName.bestGuess(nodeKeyFqName.asString())).addMember(
CLASS_PLACEHOLDER,
ClassName.bestGuess(ksClass.qualifiedName!!.asString())
).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.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncService
@@ -48,7 +46,6 @@ class HomePresenter(
private val homeSpacesPresenter: Presenter<HomeSpacesState>,
private val logoutPresenter: Presenter<DirectLogoutState>,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val featureFlagService: FeatureFlagService,
private val sessionStore: SessionStore,
private val announcementService: AnnouncementService,
) : Presenter<HomeState> {
@@ -69,9 +66,6 @@ class HomePresenter(
val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
val roomListState = roomListPresenter.present()
val homeSpacesState = homeSpacesPresenter.present()
val isSpaceFeatureEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Space)
}.collectAsState(initial = false)
var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) }
val currentHomeNavigationBarItem by remember {
derivedStateOf {
@@ -117,7 +111,6 @@ class HomePresenter(
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
directLogoutState = directLogoutState,
isSpaceFeatureEnabled = isSpaceFeatureEnabled,
eventSink = ::handleEvent,
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ camera = "1.5.2"
work = "2.11.0"
# Compose
compose_bom = "2025.12.00"
compose_bom = "2025.12.01"
# Coroutines
coroutines = "1.10.2"
@@ -32,7 +32,7 @@ accompanist = "0.37.3"
# Test
test_core = "1.7.0"
roborazzi = "1.52.0"
roborazzi = "1.55.0"
# Jetbrain
datetime = "0.7.1"
@@ -44,7 +44,7 @@ coil = "3.3.0"
showkase = "1.0.5"
appyx = "1.7.1"
sqldelight = "2.2.1"
wysiwyg = "2.40.0"
wysiwyg = "2.41.0"
telephoto = "0.18.0"
haze = "1.7.1"
@@ -52,7 +52,7 @@ haze = "1.7.1"
dependencyAnalysis = "3.5.1"
# DI
metro = "0.9.2"
metro = "0.9.3"
# Auto service
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
# 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.
matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.12.19"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.1.7"
# Others
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" }
sqlcipher = "net.zetetic:sqlcipher-android:4.12.0"
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"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", 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"
# 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"
# main branch can be tested replacing the version with main-SNAPSHOT
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.ContributesBinding
import dev.zacsweers.metro.Provider
import dev.zacsweers.metro.SingleIn
import kotlinx.serialization.json.Json
/**
* Provides a Json instance configured to ignore unknown keys.
*/
typealias JsonProvider = Provider<Json>
fun interface JsonProvider {
operator fun invoke(): Json
}
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)

View File

@@ -17,13 +17,14 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.compoundTypography
@Preview
@Composable
internal fun TypographyPreview() = ElementTheme {
Surface {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
with(ElementTheme.materialTypography) {
with(compoundTypography) {
TypographyTokenPreview(displayLarge, "Display large")
TypographyTokenPreview(displayMedium, "Display medium")
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
private fun TypographyTokenPreview(style: TextStyle, text: String) {
Text(text = text, style = style)

View File

@@ -62,14 +62,6 @@ object ElementTheme {
*/
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.
*/

View File

@@ -8,18 +8,10 @@
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 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.theme.ElementTheme
import io.element.android.compound.tokens.generated.TypographyTokens
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@@ -32,35 +24,7 @@ class CompoundTypographyTest {
@Config(sdk = [35], qualifiers = "h2048dp-xxhdpi")
fun screenshots() {
captureRoboImage(file = screenshotFile("Compound Typography.png")) {
ElementTheme {
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")
}
}
}
}
CompoundTypographyPreview()
}
}
@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(
modifier = modifier.toggleable(
value = true,
role = Role.Companion.Checkbox,
role = Role.Checkbox,
enabled = enabled,
onValueChange = {},
),

View File

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

View File

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

View File

@@ -57,14 +57,14 @@ fun ErrorDialogWithDoNotShowAgain(
Column {
Text(
text = content,
style = ElementTheme.materialTypography.bodyMedium,
style = ElementTheme.typography.fontBodyMdRegular,
)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = doNotShowAgain, onCheckedChange = { doNotShowAgain = it })
Text(
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 {
Text(
text = content,
style = ElementTheme.materialTypography.bodyMedium,
style = ElementTheme.typography.fontBodyMdRegular,
)
}
}

View File

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

View File

@@ -15,9 +15,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
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.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TextFieldLabelScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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)
@Composable
internal fun FilledTextFieldLightPreview() =

View File

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

View File

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

View File

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

View File

@@ -25,10 +25,14 @@ class DefaultFeatureFlagService(
private val featuresProvider: FeaturesProvider,
) : FeatureFlagService {
override fun isFeatureEnabledFlow(feature: Feature): Flow<Boolean> {
return providers.filter { it.hasFeature(feature) }
.maxByOrNull(FeatureFlagProvider::priority)
?.isFeatureEnabledFlow(feature)
?: flowOf(feature.defaultValue(buildMeta))
return if (feature.isFinished) {
flowOf(feature.defaultValue(buildMeta))
} else {
providers.filter { it.hasFeature(feature) }
.maxByOrNull(FeatureFlagProvider::priority)
?.isFeatureEnabledFlow(feature)
?: flowOf(feature.defaultValue(buildMeta))
}
}
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>
/**
* 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.JoinedRoom
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.join.JoinRule
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.KnockRequestsListener
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.TimelineFilter
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 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.ShieldState
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.EventTimelineItem as RustEventTimelineItem
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo
@@ -58,7 +58,7 @@ class EventTimelineItemMapper(
content = contentMapper.map(content),
origin = origin?.map(),
timelineItemDebugInfoProvider = { lazyProvider.debugInfo().map() },
messageShieldProvider = { strict -> lazyProvider.getShields(strict)?.map() },
messageShieldProvider = { strict -> lazyProvider.getShields(strict).map() },
sendHandleProvider = { lazyProvider.getSendHandle()?.let(::RustSendHandle) }
)
}
@@ -182,13 +182,13 @@ private fun ShieldState?.map(): MessageShield? {
is ShieldState.Red -> true
}
return when (shieldStateCode) {
ShieldStateCode.AUTHENTICITY_NOT_GUARANTEED -> MessageShield.AuthenticityNotGuaranteed(isCritical)
ShieldStateCode.UNKNOWN_DEVICE -> MessageShield.UnknownDevice(isCritical)
ShieldStateCode.UNSIGNED_DEVICE -> MessageShield.UnsignedDevice(isCritical)
ShieldStateCode.UNVERIFIED_IDENTITY -> MessageShield.UnverifiedIdentity(isCritical)
ShieldStateCode.SENT_IN_CLEAR -> MessageShield.SentInClear(isCritical)
ShieldStateCode.VERIFICATION_VIOLATION -> MessageShield.VerificationViolation(isCritical)
ShieldStateCode.MISMATCHED_SENDER -> MessageShield.MismatchedSender(isCritical)
TimelineEventShieldStateCode.AUTHENTICITY_NOT_GUARANTEED -> MessageShield.AuthenticityNotGuaranteed(isCritical)
TimelineEventShieldStateCode.UNKNOWN_DEVICE -> MessageShield.UnknownDevice(isCritical)
TimelineEventShieldStateCode.UNSIGNED_DEVICE -> MessageShield.UnsignedDevice(isCritical)
TimelineEventShieldStateCode.UNVERIFIED_IDENTITY -> MessageShield.UnverifiedIdentity(isCritical)
TimelineEventShieldStateCode.SENT_IN_CLEAR -> MessageShield.SentInClear(isCritical)
TimelineEventShieldStateCode.VERIFICATION_VIOLATION -> MessageShield.VerificationViolation(isCritical)
TimelineEventShieldStateCode.MISMATCHED_SENDER -> MessageShield.MismatchedSender(isCritical)
}
}

View File

@@ -72,7 +72,8 @@ class RustSessionVerificationService(
// Listen for changes in verification status and update accordingly
private val verificationStateListenerTaskHandle = encryptionService.verificationStateListener(object : VerificationStateListener {
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")
return
}

View File

@@ -21,22 +21,24 @@ import org.matrix.rustcomponents.sdk.ShieldState
import org.matrix.rustcomponents.sdk.TimelineItemContent
import uniffi.matrix_sdk_ui.EventItemOrigin
fun aRustEventTimelineItem(
internal fun aRustEventTimelineItem(
isRemote: Boolean = true,
eventOrTransactionId: EventOrTransactionId = EventOrTransactionId.EventId(AN_EVENT_ID.value),
sender: String = A_USER_ID.value,
senderProfile: ProfileDetails = ProfileDetails.Unavailable,
isOwn: Boolean = true,
isEditable: Boolean = true,
content: TimelineItemContent = aRustTimelineItemMessageContent(),
content: TimelineItemContent = aRustTimelineItemContentMsgLike(),
timestamp: ULong = 0uL,
debugInfo: EventTimelineItemDebugInfo = anEventTimelineItemDebugInfo(),
localSendState: EventSendState? = null,
readReceipts: Map<String, Receipt> = emptyMap(),
origin: EventItemOrigin? = EventItemOrigin.SYNC,
canBeRepliedTo: Boolean = true,
shieldsState: ShieldState? = null,
shieldsState: ShieldState = ShieldState.None,
localCreatedAt: ULong? = null,
forwarder: String? = null,
forwarderProfile: ProfileDetails? = null,
) = EventTimelineItem(
isRemote = isRemote,
eventOrTransactionId = eventOrTransactionId,
@@ -54,5 +56,7 @@ fun aRustEventTimelineItem(
lazyProvider = FakeFfiLazyTimelineItemProvider(
debugInfo = debugInfo,
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
fun anEventTimelineItemDebugInfo(
internal fun anEventTimelineItemDebugInfo(
model: String = "model",
originalJson: 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_USER_NAME
import org.matrix.rustcomponents.sdk.Action
import org.matrix.rustcomponents.sdk.BatchNotificationResult
import org.matrix.rustcomponents.sdk.JoinRule
import org.matrix.rustcomponents.sdk.NotificationEvent
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.TimelineEvent
fun aRustNotificationItem(
internal fun aRustNotificationItem(
event: NotificationEvent = aRustNotificationEventTimeline(),
senderInfo: NotificationSenderInfo = aRustNotificationSenderInfo(),
roomInfo: NotificationRoomInfo = aRustNotificationRoomInfo(),
@@ -39,13 +40,13 @@ fun aRustNotificationItem(
actions = actions,
)
fun aRustBatchNotificationResult(
internal fun aRustBatchNotificationResultOk(
notificationStatus: NotificationStatus = NotificationStatus.Event(aRustNotificationItem()),
) = org.matrix.rustcomponents.sdk.BatchNotificationResult.Ok(
) = BatchNotificationResult.Ok(
status = notificationStatus,
)
fun aRustNotificationSenderInfo(
internal fun aRustNotificationSenderInfo(
displayName: String? = A_USER_NAME,
avatarUrl: String? = null,
isNameAmbiguous: Boolean = false,
@@ -55,7 +56,7 @@ fun aRustNotificationSenderInfo(
isNameAmbiguous = isNameAmbiguous,
)
fun aRustNotificationRoomInfo(
internal fun aRustNotificationRoomInfo(
displayName: String = A_ROOM_NAME,
avatarUrl: String? = null,
canonicalAlias: String? = null,
@@ -77,7 +78,7 @@ fun aRustNotificationRoomInfo(
isSpace = isSpace,
)
fun aRustNotificationEventTimeline(
internal fun aRustNotificationEventTimeline(
event: TimelineEvent = FakeFfiTimelineEvent(),
) = NotificationEvent.Timeline(
event = event,

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ import org.matrix.rustcomponents.sdk.RoomPowerLevels
import org.matrix.rustcomponents.sdk.SuccessorRoom
import uniffi.matrix_sdk_base.EncryptionState
fun aRustRoomInfo(
internal fun aRustRoomInfo(
id: String = A_ROOM_ID.value,
displayName: 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 uniffi.matrix_sdk.RoomMemberRole
fun aRustRoomMember(
internal fun aRustRoomMember(
userId: UserId,
displayName: 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.RoomNotificationSettings
fun aRustRoomNotificationSettings(
internal fun aRustRoomNotificationSettings(
mode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES,
isDefault: Boolean = true,
) = RoomNotificationSettings(

View File

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

View File

@@ -18,14 +18,12 @@ internal fun aRustSession(
proxy: SlidingSyncVersion = SlidingSyncVersion.NONE,
accessToken: String = "accessToken",
refreshToken: String = "refreshToken",
): Session {
return Session(
accessToken = accessToken,
refreshToken = refreshToken,
userId = A_USER_ID.value,
deviceId = A_DEVICE_ID.value,
homeserverUrl = A_HOMESERVER_URL,
oidcData = null,
slidingSyncVersion = proxy,
)
}
) = Session(
accessToken = accessToken,
refreshToken = refreshToken,
userId = A_USER_ID.value,
deviceId = A_DEVICE_ID.value,
homeserverUrl = A_HOMESERVER_URL,
oidcData = null,
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.SpaceRoom
fun aRustSpaceRoom(
internal fun aRustSpaceRoom(
roomId: RoomId = A_ROOM_ID,
isDirect: Boolean = false,
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.TimelineEventContent
fun aRustTimelineEventContentMessageLike(
internal fun aRustTimelineEventContentMessageLike(
content: MessageLikeEventContent = aRustMessageLikeEventContentRoomMessage(),
): TimelineEventContent.MessageLike {
return TimelineEventContent.MessageLike(
content = content,
)
}
) = TimelineEventContent.MessageLike(
content = content,
)
fun aRustMessageLikeEventContentRoomMessage(
internal fun aRustMessageLikeEventContentRoomMessage(
messageType: MessageType = aRustMessageTypeText(),
inReplyToEventId: String? = null,
) = MessageLikeEventContent.RoomMessage(
@@ -31,13 +29,13 @@ fun aRustMessageLikeEventContentRoomMessage(
inReplyToEventId = inReplyToEventId,
)
fun aRustMessageTypeText(
internal fun aRustMessageTypeText(
content: TextMessageContent = aRustTextMessageContent(),
) = MessageType.Text(
content = content,
)
fun aRustTextMessageContent(
internal fun aRustTextMessageContent(
body: String = A_MESSAGE,
formatted: FormattedBody? = null,
) = TextMessageContent(

View File

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

View File

@@ -19,14 +19,12 @@ internal fun aRustUnableToDecryptInfo(
userTrustsOwnIdentity: Boolean = false,
senderHomeserver: String = "",
ownHomeserver: String = "",
): UnableToDecryptInfo {
return UnableToDecryptInfo(
eventId = eventId,
timeToDecryptMs = timeToDecryptMs,
cause = cause,
eventLocalAgeMillis = eventLocalAgeMillis,
userTrustsOwnIdentity = userTrustsOwnIdentity,
senderHomeserver = senderHomeserver,
ownHomeserver = ownHomeserver,
)
}
) = UnableToDecryptInfo(
eventId = eventId,
timeToDecryptMs = timeToDecryptMs,
cause = cause,
eventLocalAgeMillis = eventLocalAgeMillis,
userTrustsOwnIdentity = userTrustsOwnIdentity,
senderHomeserver = senderHomeserver,
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 org.matrix.rustcomponents.sdk.UserProfile
fun aRustUserProfile(
internal fun aRustUserProfile(
userId: String = A_USER_ID.value,
displayName: String = "displayName",
avatarUrl: String = "avatarUrl",

View File

@@ -17,7 +17,7 @@ import org.matrix.rustcomponents.sdk.ShieldState
class FakeFfiLazyTimelineItemProvider(
private val debugInfo: EventTimelineItemDebugInfo = anEventTimelineItemDebugInfo(),
private val shieldsState: ShieldState? = null,
private val shieldsState: ShieldState = ShieldState.None,
) : LazyTimelineItemProvider(NoHandle) {
override fun getShields(strict: Boolean) = shieldsState
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.notification.NotificationContent
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.aRustNotificationItem
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiNotificationClient
@@ -38,7 +38,7 @@ class RustNotificationServiceTest {
@Test
fun test() = runTest {
val notificationClient = FakeFfiNotificationClient(
notificationItemResult = mapOf(AN_EVENT_ID.value to aRustBatchNotificationResult()),
notificationItemResult = mapOf(AN_EVENT_ID.value to aRustBatchNotificationResultOk()),
)
val sut = createRustNotificationService(
notificationClient = notificationClient,
@@ -66,10 +66,10 @@ class RustNotificationServiceTest {
}
val notificationClient = FakeFfiNotificationClient(
notificationItemResult = mapOf(
AN_EVENT_ID.value to aRustBatchNotificationResult(
AN_EVENT_ID.value to aRustBatchNotificationResultOk(
notificationStatus = NotificationStatus.Event(aRustNotificationItem(aRustNotificationEventTimeline(faultyEvent)))
),
AN_EVENT_ID_2.value to aRustBatchNotificationResult()
AN_EVENT_ID_2.value to aRustBatchNotificationResultOk()
),
)
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.RoomMembersState
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.join.JoinRule
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.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.TestScope
@@ -83,6 +85,8 @@ class FakeJoinedRoom(
private val updateJoinRuleResult: (JoinRule) -> Result<Unit> = { lambdaError() },
private val setSendQueueEnabledResult: (Boolean) -> Unit = { _: Boolean -> },
) : JoinedRoom, BaseRoom by baseRoom {
private val sendQueueUpdates = MutableSharedFlow<SendQueueUpdate>(extraBufferCapacity = 10)
fun givenRoomMembersState(state: RoomMembersState) {
baseRoom.givenRoomMembersState(state)
}
@@ -219,6 +223,10 @@ class FakeJoinedRoom(
withdrawVerificationAndResendResult(userIds, sendHandle)
}
override fun subscribeToSendQueueUpdates(): Flow<SendQueueUpdate> {
return sendQueueUpdates
}
private suspend fun simulateSendMediaProgress(progressCallback: ProgressCallback?) {
progressCallbackValues.forEach { (current, total) ->
progressCallback?.onProgress(current, total)
@@ -229,4 +237,8 @@ class FakeJoinedRoom(
fun emitSyncUpdate() {
(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.testtags)
implementation(libs.coil.compose)
implementation(libs.jsoup)
implementation(libs.matrix.richtexteditor)
implementation(projects.libraries.previewutils)
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.timeline.item.event.FormattedBody
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
/**
@@ -34,9 +34,9 @@ fun FormattedBody.toHtmlDocument(
?.trimEnd()
?.let { formattedBody ->
val dom = if (prefix != null) {
Jsoup.parse("$prefix $formattedBody")
HtmlToDomParser.document("$prefix $formattedBody")
} else {
Jsoup.parse(formattedBody)
HtmlToDomParser.document(formattedBody)
}
// Prepend `@` to mentions

View File

@@ -55,8 +55,15 @@ private class PlainTextNodeVisitor : NodeVisitor {
private val builder = StringBuilder()
override fun head(node: Node, depth: Int) {
if (node is TextNode && node.text().isNotBlank()) {
builder.append(node.text())
if (node is TextNode) {
// 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") {
val index = node.elementSiblingIndex() + 1
val isOrdered = node.parent()?.nodeName()?.lowercase() == "ol"

View File

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

View File

@@ -47,6 +47,12 @@ interface MediaPlayer : AutoCloseable {
*/
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.
*/

View File

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

View File

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

View File

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

View File

@@ -96,6 +96,10 @@ class FakeMediaPlayer(
}
}
override fun setPlaybackSpeed(speed: Float) {
// no-op
}
override fun close() {
// 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.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -38,6 +39,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.atoms.PlaybackSpeedButton
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
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.aMediaItemVoice
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.VoiceMessageStateProvider
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
@@ -92,7 +94,7 @@ private fun VoiceInfoRow(
onLongClick: () -> Unit,
) {
fun playPause() {
state.eventSink(VoiceMessageEvents.PlayPause)
state.eventSink(VoiceMessageEvent.PlayPause)
}
Row(
@@ -112,21 +114,30 @@ private fun VoiceInfoRow(
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (state.button) {
VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.Button.Downloading -> ProgressButton()
VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false)
when (state.buttonType) {
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
}
Spacer(Modifier.width(8.dp))
Text(
text = 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,
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
PlaybackSpeedButton(
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))
WaveformPlaybackView(
modifier = Modifier
@@ -136,7 +147,7 @@ private fun VoiceInfoRow(
playbackProgress = state.progress,
waveform = voice.mediaInfo.waveform.orEmpty().toImmutableList(),
onSeek = {
state.eventSink(VoiceMessageEvents.Seek(it))
state.eventSink(VoiceMessageEvent.Seek(it))
},
seekEnabled = true,
)

View File

@@ -75,7 +75,7 @@ class DefaultRoomGroupMessageCreator(
hasSmartReplyError = smartReplyErrors.isNotEmpty(),
shouldBing = events.any { it.noisy },
customSound = events.last().soundName,
isUpdated = events.last().isUpdated,
isUpdated = events.last().let { it.isUpdated || it.outGoingMessage },
),
threadId = threadId,
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_sender_me">"Moi"</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_thread_in_room">"Discussion dans %1$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_take_photo">"Prendre une photo"</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_unpin">"Désépingler"</string>
<string name="action_view">"Voir"</string>
@@ -235,6 +236,7 @@ Raison : %1$s."</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_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_more">"Chargement…"</string>
<plurals name="common_many_members">
@@ -252,6 +254,7 @@ Raison : %1$s."</string>
<string name="common_message_removed">"Message supprimé"</string>
<string name="common_modern">"Moderne"</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_no_results">"Aucun résultat"</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_message">"Nous avons rencontré un problème. Veuillez réessayer."</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">
<item quantity="one">"%1$d Espace"</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_for_decryption_key">"En attente de la clé de déchiffrement"</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_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>

View File

@@ -11,7 +11,7 @@
</plurals>
<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_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_hide_password">"Sakrij zaporku"</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="action_accept">"Prihvati"</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_call">"Poziv"</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_take_photo">"Uslikaj"</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_unpin">"Otkvači"</string>
<string name="action_view">"Prikaz"</string>

View File

@@ -8,7 +8,8 @@
package io.element.android.libraries.voiceplayer.api
sealed interface VoiceMessageEvents {
data object PlayPause : VoiceMessageEvents
data class Seek(val percentage: Float) : VoiceMessageEvents
sealed interface VoiceMessageEvent {
data object PlayPause : VoiceMessageEvent
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