Merge branch 'release/0.7.0' into main

This commit is contained in:
Benoit Marty 2024-10-10 20:23:52 +02:00
commit 1ad7a1fd49
197 changed files with 1305 additions and 875 deletions

3
.gitignore vendored
View file

@ -65,6 +65,9 @@ captures/
.idea/shelf
.idea/sonarlint
# .kotlin folder
.kotlin
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks

2
.idea/kotlinc.xml generated
View file

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

View file

@ -1,3 +1,77 @@
Changes in Element X v0.6.5 (2024-10-09)
========================================
## What's Changed
### ✨ Features
* Add developer setting to hide images in the timeline by @bmarty in https://github.com/element-hq/element-x-android/pull/3592
* Warn the user when unverified user has changed their identity by @bmarty in https://github.com/element-hq/element-x-android/pull/3621
### 🙌 Improvements
* Handle no network error when starting Element Call. by @bmarty in https://github.com/element-hq/element-x-android/pull/3527
### 🐛 Bugfixes
* Fix room settings not treating unencrypted DMs as DMs by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3545
* Fix crash when aspectRatio is null. by @bmarty in https://github.com/element-hq/element-x-android/pull/3561
* Don't delete uploaded logs by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3540
* Don't display security banner for unknown RecoveryState by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3579
* Fix the logic of the room list banner state by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3615
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3560
* Sync Strings - import translations to Persian by @ElementBot in https://github.com/element-hq/element-x-android/pull/3612
### 🧱 Build
* Introduce ModulesConfig by @bmarty in https://github.com/element-hq/element-x-android/pull/3530
* Centralise the DI code generation logic by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3562
* Update Gradle impl module template with `setupAnvil()` call by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3563
* Use Anvil KSP instead of the Square KAPT one by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3564
* Upgrade the used JDK in the project to v21 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3582
* Merge unit, screenshot tests and coverage in a single CI call by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3593
* Disable configuration cache in the CI by default by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3601
* Fix screenshot recording in CI by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3607
* Ensure the CI compile and execute all the unit tests. by @bmarty in https://github.com/element-hq/element-x-android/pull/3617
### Dependency upgrades
* Update dependency androidx.compose:compose-bom to v2024.09.00 by @renovate in https://github.com/element-hq/element-x-android/pull/3399
* Update dependency androidx.compose:compose-bom to v2024.09.02 by @renovate in https://github.com/element-hq/element-x-android/pull/3544
* Update dependency io.element.android:compound-android to v0.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3524
* Update dependency com.google.firebase:firebase-bom to v33.3.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3549
* Update dependency org.maplibre.gl:android-sdk to v11.5.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3550
* Update dependency org.maplibre.gl:android-plugin-annotation-v9 to v3.0.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3505
* Update dependency androidx.webkit:webkit to v1.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3520
* Update dependency com.posthog:posthog-android to v3.7.5 by @renovate in https://github.com/element-hq/element-x-android/pull/3546
* Update gradle-update/update-gradle-wrapper-action action to v2 by @renovate in https://github.com/element-hq/element-x-android/pull/3551
* Update dependency com.lemonappdev:konsist to v0.16.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3371
* Update android.gradle.plugin to v8.6.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3504
* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.49 by @renovate in https://github.com/element-hq/element-x-android/pull/3553
* Update lifecycle to v2.8.6 by @renovate in https://github.com/element-hq/element-x-android/pull/3398
* Update dependency com.google.accompanist:accompanist-permissions to v0.36.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3400
* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.50 by @renovate in https://github.com/element-hq/element-x-android/pull/3565
* Update dependency com.google.firebase:firebase-bom to v33.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3578
* Update android.gradle.plugin to v8.7.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3577
* Update dependency com.posthog:posthog-android to v3.8.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3591
* dependency: Bump rust sdk to 0.2.51 by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/3602
* chore(deps): update dependencyanalysis to v2.1.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3559
* Update wysiwyg to v2.37.13 by @renovate in https://github.com/element-hq/element-x-android/pull/3596
* fix(deps): update dependency io.nlopez.compose.rules:detekt to v0.4.15 by @renovate in https://github.com/element-hq/element-x-android/pull/3595
* fix(deps): update dependency com.google.testparameterinjector:test-parameter-injector to v1.18 by @renovate in https://github.com/element-hq/element-x-android/pull/3606
* fix(deps): update dependency com.squareup:kotlinpoet-ksp to v1.18.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3580
* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.52 by @renovate in https://github.com/element-hq/element-x-android/pull/3619
* SDK 0.2.53 19b9a73ecc3e31d502dbf0c5850bfdfaddf02afe by @bmarty in https://github.com/element-hq/element-x-android/pull/3622
* Update dependency org.maplibre.gl:android-sdk to v11.5.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3608
### Others
* rename invisible flag to onlySignedDeviceIsolation flag by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/3542
* Fix image viewer glitch by @ganfra in https://github.com/element-hq/element-x-android/pull/3537
* Prefix message sent by the current user by `You` instead of the sender name. by @bmarty in https://github.com/element-hq/element-x-android/pull/3547
* timeline : remove animateItem by @ganfra in https://github.com/element-hq/element-x-android/pull/3548
* Fix a couple of build-time warnings in Gradle output by @frebib in https://github.com/element-hq/element-x-android/pull/3349
* Use MSC2530 filename when loading media by @frebib in https://github.com/element-hq/element-x-android/pull/3567
* Prevent crash with duplicate room suggestion by @frebib in https://github.com/element-hq/element-x-android/pull/3576
* Add unit tests on TimelineItemsSubscriber by @bmarty in https://github.com/element-hq/element-x-android/pull/3554
* Fix tests on develop by @bmarty in https://github.com/element-hq/element-x-android/pull/3585
* Timeline better jump to behaviours by @ganfra in https://github.com/element-hq/element-x-android/pull/3597
* Fix building the app using a local SDK. by @bmarty in https://github.com/element-hq/element-x-android/pull/3604
* crypto: Use OnlySigned isolation flag to setup decryption trust req. by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/3569
* Fix black-on-black status bars with hidden media by @frebib in https://github.com/element-hq/element-x-android/pull/3611
* Remove supportSlidingSync boolean. by @bmarty in https://github.com/element-hq/element-x-android/pull/3609
* Ensure that `Presenter`s do not depend on other presenters. by @bmarty in https://github.com/element-hq/element-x-android/pull/3618
* Do not render pin violation in clear rooms. by @bmarty in https://github.com/element-hq/element-x-android/pull/3630
Changes in Element X v0.6.4 (2024-09-25)
========================================

View file

@ -8,6 +8,7 @@
package io.element.android.appconfig
object LearnMoreConfig {
const val ENCRYPTION_URL: String = "https://element.io/help#encryption"
const val SECURE_BACKUP_URL: String = "https://element.io/help#encryption5"
const val IDENTITY_CHANGE_URL: String = "https://element.io/help#encryption18"
}

View file

@ -45,6 +45,8 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.getRoomInfoFlow
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import kotlinx.coroutines.flow.combine
@ -120,12 +122,9 @@ class RoomFlowNode @AssistedInject constructor(
}
private fun subscribeToRoomInfoFlow(roomId: RoomId, serverNames: List<String>) {
val roomInfoFlow = client.getRoomInfoFlow(
roomId = roomId
).map { it.getOrNull() }
val isSpaceFlow = roomInfoFlow.map { it?.isSpace.orFalse() }.distinctUntilChanged()
val currentMembershipFlow = roomInfoFlow.map { it?.currentUserMembership }.distinctUntilChanged()
val roomInfoFlow = client.getRoomInfoFlow(roomIdOrAlias = roomId.toRoomIdOrAlias())
val isSpaceFlow = roomInfoFlow.map { it.getOrNull()?.isSpace.orFalse() }.distinctUntilChanged()
val currentMembershipFlow = roomInfoFlow.map { it.getOrNull()?.currentUserMembership }.distinctUntilChanged()
combine(currentMembershipFlow, isSpaceFlow) { membership, isSpace ->
Timber.d("Room membership: $membership")
when (membership) {

View file

@ -478,7 +478,7 @@ class LoggedInPresenterTest {
distributors = listOf(Distributor("aDistributorValue1", "aDistributorName1")),
currentDistributor = { null },
),
registerWithLambda: suspend (MatrixClient, PushProvider, Distributor) -> Result<Unit> = { _, _, _ ->
registerWithLambda: (MatrixClient, PushProvider, Distributor) -> Result<Unit> = { _, _, _ ->
Result.success(Unit)
},
selectPushProviderLambda: (MatrixClient, PushProvider) -> Unit = { _, _ -> lambdaError() },

View file

@ -18,6 +18,7 @@ plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.anvil) apply false
alias(libs.plugins.kotlin.jvm) apply false
@ -82,20 +83,15 @@ allprojects {
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
// Warnings are potential errors, so stop ignoring them
// This is disabled by default, but the CI will enforce this.
// You can override by passing `-PallWarningsAsErrors=true` in the command line
// Or add a line with "allWarningsAsErrors=true" in your ~/.gradle/gradle.properties file
kotlinOptions.allWarningsAsErrors = project.properties["allWarningsAsErrors"] == "true"
compilerOptions {
// Warnings are potential errors, so stop ignoring them
// This is disabled by default, but the CI will enforce this.
// You can override by passing `-PallWarningsAsErrors=true` in the command line
// Or add a line with "allWarningsAsErrors=true" in your ~/.gradle/gradle.properties file
allWarningsAsErrors = project.properties["allWarningsAsErrors"] == "true"
kotlinOptions {
/*
// Uncomment to suppress Compose Kotlin compiler compatibility warning
freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true"
)
*/
// freeCompilerArgs.addAll(listOf("-P", "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true"))
}
}
}
@ -192,19 +188,23 @@ subprojects {
subprojects {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
kotlinOptions {
compilerOptions {
if (project.findProperty("composeCompilerReports") == "true") {
freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
"${project.layout.buildDirectory.asFile.get().absolutePath}/compose_compiler"
freeCompilerArgs.addAll(
listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
"${project.layout.buildDirectory.asFile.get().absolutePath}/compose_compiler"
)
)
}
if (project.findProperty("composeCompilerMetrics") == "true") {
freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
"${project.layout.buildDirectory.asFile.get().absolutePath}/compose_compiler"
freeCompilerArgs.addAll(
listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
"${project.layout.buildDirectory.asFile.get().absolutePath}/compose_compiler"
)
)
}
}

View file

@ -0,0 +1,2 @@
Main changes in this version: bug fixes and performance improvement.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -1,3 +1,5 @@
import extension.setupAnvil
/*
* Copyright 2023, 2024 New Vector Ltd.
*
@ -7,13 +9,14 @@
plugins {
id("io.element.android-library")
alias(libs.plugins.anvil)
}
android {
namespace = "io.element.android.features.cachecleaner.api"
}
setupAnvil()
dependencies {
implementation(projects.libraries.architecture)
implementation(libs.androidx.startup)

View file

@ -8,7 +8,9 @@
package io.element.android.features.call.impl.ui
import android.annotation.SuppressLint
import android.util.Log
import android.view.ViewGroup
import android.webkit.ConsoleMessage
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebView
@ -43,6 +45,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
import timber.log.Timber
typealias RequestPermissionCallback = (Array<String>) -> Unit
@ -189,6 +192,25 @@ private fun WebView.setup(
override fun onPermissionRequest(request: PermissionRequest) {
onPermissionsRequested(request)
}
override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
val priority = when (consoleMessage.messageLevel()) {
ConsoleMessage.MessageLevel.ERROR -> Log.ERROR
ConsoleMessage.MessageLevel.WARNING -> Log.WARN
else -> Log.DEBUG
}
Timber.tag("WebView").log(
priority = priority,
message = buildString {
append(consoleMessage.sourceId())
append(":")
append(consoleMessage.lineNumber())
append(" ")
append(consoleMessage.message())
},
)
return true
}
}
}

View file

@ -109,12 +109,7 @@ class CallScreenPresenterTest {
assertThat(initialState.isInWidgetMode).isTrue()
assertThat(widgetProvider.getWidgetCalled).isTrue()
assertThat(widgetDriver.runCalledCount).isEqualTo(1)
// Called several times because of the recomposition
analyticsLambda.assertions().isCalledExactly(2)
.withSequence(
listOf(value(MobileScreen.ScreenName.RoomCall)),
listOf(value(MobileScreen.ScreenName.RoomCall))
)
analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall))
sendCallNotificationIfNeededLambda.assertions().isCalledOnce()
}
}

View file

@ -1,3 +1,5 @@
import extension.setupAnvil
/*
* Copyright 2024 New Vector Ltd.
*
@ -6,13 +8,14 @@
*/
plugins {
id("io.element.android-library")
alias(libs.plugins.anvil)
}
android {
namespace = "io.element.android.features.enterprise.impl"
}
setupAnvil()
dependencies {
implementation(projects.anvilannotations)
api(projects.features.enterprise.api)

View file

@ -1,3 +1,5 @@
import extension.setupAnvil
/*
* Copyright 2024 New Vector Ltd.
*
@ -7,7 +9,6 @@
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
id("kotlin-parcelize")
}
@ -15,6 +16,8 @@ android {
namespace = "io.element.android.features.ftue.test"
}
setupAnvil()
dependencies {
implementation(projects.features.ftue.api)
implementation(projects.tests.testutils)

View file

@ -99,7 +99,7 @@ class AcceptDeclineInvitePresenterTest {
InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite
)
}
skipItems(1)
skipItems(2)
awaitItem().also { state ->
assertThat(state.declineAction).isInstanceOf(AsyncAction.Failure::class.java)
state.eventSink(
@ -147,7 +147,7 @@ class AcceptDeclineInvitePresenterTest {
InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite
)
}
skipItems(1)
skipItems(2)
awaitItem().also { state ->
assertThat(state.declineAction).isInstanceOf(AsyncAction.Success::class.java)
}

View file

@ -33,6 +33,8 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.getRoomInfoFlow
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomType
@ -70,7 +72,7 @@ class JoinRoomPresenter @AssistedInject constructor(
override fun present(): JoinRoomState {
val coroutineScope = rememberCoroutineScope()
var retryCount by remember { mutableIntStateOf(0) }
val roomInfo by matrixClient.getRoomInfoFlow(roomId).collectAsState(initial = Optional.empty())
val roomInfo by matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias()).collectAsState(initial = Optional.empty())
val joinAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val contentState by produceState<ContentState>(
@ -204,7 +206,7 @@ internal fun MatrixRoomInfo.toContentState(): ContentState {
name = name,
topic = topic,
alias = canonicalAlias,
numberOfMembers = activeMembersCount,
numberOfMembers = activeMembersCount.toLong(),
isDm = isDm,
roomType = if (isSpace) RoomType.Space else RoomType.Room,
roomAvatarUrl = avatarUrl,

View file

@ -19,7 +19,6 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.ui.model.InviteSender
open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {

View file

@ -32,8 +32,8 @@ import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SERVER_LIST
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
import io.element.android.libraries.matrix.ui.model.toInviteSender
import io.element.android.tests.testutils.WarmUpRule
@ -67,10 +67,10 @@ class JoinRoomPresenterTest {
@Test
fun `present - when room is joined then content state is filled with his data`() = runTest {
val roomInfo = aRoomInfo()
val roomSummary = aRoomSummary()
val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomInfo))
getRoomSummaryFlowLambda = { _ ->
flowOf(Optional.of(roomSummary))
}
}
val presenter = createJoinRoomPresenter(
@ -81,22 +81,22 @@ class JoinRoomPresenterTest {
awaitItem().also { state ->
val contentState = state.contentState as ContentState.Loaded
assertThat(contentState.roomId).isEqualTo(A_ROOM_ID)
assertThat(contentState.name).isEqualTo(roomInfo.name)
assertThat(contentState.topic).isEqualTo(roomInfo.topic)
assertThat(contentState.alias).isEqualTo(roomInfo.canonicalAlias)
assertThat(contentState.numberOfMembers).isEqualTo(roomInfo.activeMembersCount)
assertThat(contentState.isDm).isEqualTo(roomInfo.isDirect)
assertThat(contentState.roomAvatarUrl).isEqualTo(roomInfo.avatarUrl)
assertThat(contentState.name).isEqualTo(roomSummary.info.name)
assertThat(contentState.topic).isEqualTo(roomSummary.info.topic)
assertThat(contentState.alias).isEqualTo(roomSummary.info.canonicalAlias)
assertThat(contentState.numberOfMembers).isEqualTo(roomSummary.info.activeMembersCount)
assertThat(contentState.isDm).isEqualTo(roomSummary.info.isDirect)
assertThat(contentState.roomAvatarUrl).isEqualTo(roomSummary.info.avatarUrl)
}
}
}
@Test
fun `present - when room is invited then join authorization is equal to invited`() = runTest {
val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.INVITED)
val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.INVITED)
val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomInfo))
getRoomSummaryFlowLambda = { _ ->
flowOf(Optional.of(roomSummary))
}
}
val presenter = createJoinRoomPresenter(
@ -114,13 +114,13 @@ class JoinRoomPresenterTest {
fun `present - when room is invited then join authorization is equal to invited, an inviter is provided`() = runTest {
val inviter = aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob")
val expectedInviteSender = inviter.toInviteSender()
val roomInfo = aRoomInfo(
val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED,
inviter = inviter,
)
val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomInfo))
getRoomSummaryFlowLambda = { _ ->
flowOf(Optional.of(roomSummary))
}
}
val presenter = createJoinRoomPresenter(
@ -140,10 +140,10 @@ class JoinRoomPresenterTest {
val acceptDeclinePresenter = Presenter {
anAcceptDeclineInviteState(eventSink = eventSinkRecorder)
}
val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.INVITED)
val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.INVITED)
val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomInfo))
getRoomSummaryFlowLambda = { _ ->
flowOf(Optional.of(roomSummary))
}
}
val presenter = createJoinRoomPresenter(
@ -224,10 +224,10 @@ class JoinRoomPresenterTest {
@Test
fun `present - when room is left and public then join authorization is equal to canJoin`() = runTest {
val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.LEFT, isPublic = true)
val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.LEFT, isPublic = true)
val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomInfo))
getRoomSummaryFlowLambda = { _ ->
flowOf(Optional.of(roomSummary))
}
}
val presenter = createJoinRoomPresenter(
@ -243,10 +243,10 @@ class JoinRoomPresenterTest {
@Test
fun `present - when room is left and not public then join authorization is equal to unknown`() = runTest {
val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.LEFT, isPublic = false)
val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.LEFT, isPublic = false)
val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomInfo))
getRoomSummaryFlowLambda = { _ ->
flowOf(Optional.of(roomSummary))
}
}
val presenter = createJoinRoomPresenter(
@ -338,11 +338,15 @@ class JoinRoomPresenterTest {
awaitItem().also { state ->
state.eventSink(JoinRoomEvents.KnockRoom)
}
assertThat(awaitItem().knockAction).isEqualTo(AsyncAction.Loading)
awaitItem().also { state ->
assertThat(state.knockAction).isEqualTo(AsyncAction.Success(Unit))
fakeKnockRoom.lambda = knockRoomFailure
state.eventSink(JoinRoomEvents.KnockRoom)
}
assertThat(awaitItem().knockAction).isEqualTo(AsyncAction.Loading)
awaitItem().also { state ->
assertThat(state.knockAction).isInstanceOf(AsyncAction.Failure::class.java)
}

View file

@ -10,7 +10,6 @@ import extension.setupAnvil
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
alias(libs.plugins.anvil)
alias(libs.plugins.kotlin.serialization)
}

View file

@ -51,7 +51,7 @@ class WebViewMessageInterceptor(
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
request ?: return super.shouldOverrideUrlLoading(view, request)
request ?: return false
// Load the URL in a Chrome Custom Tab, and return true to cancel the load
onOpenExternalUrl(request.url.toString())
return true

View file

@ -30,7 +30,6 @@ import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@Composable
@ -51,6 +50,7 @@ fun QrCodeIntroView(
onBackClick = onBackClick,
iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
title = stringResource(id = R.string.screen_qr_code_login_initial_state_title, state.desktopAppName),
subTitle = stringResource(id = R.string.screen_qr_code_login_initial_state_subtitle),
content = { Content(state = state) },
buttons = { Buttons(state = state) }
)
@ -87,7 +87,7 @@ private fun ColumnScope.Buttons(
state: QrCodeIntroState,
) {
Button(
text = stringResource(id = CommonStrings.action_continue),
text = stringResource(id = R.string.screen_qr_code_login_initial_state_button_title),
modifier = Modifier.fillMaxWidth(),
onClick = {
state.eventSink.invoke(QrCodeIntroEvents.Continue)

View file

@ -60,6 +60,7 @@ Try signing in manually, or scan the QR code with another device."</string>
<string name="screen_qr_code_login_initial_state_item_3">"Select %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Link new device”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Scan the QR code with this device"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Only available if your account provider supports it."</string>
<string name="screen_qr_code_login_initial_state_title">"Open %1$s on another device to get the QR code"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Use the QR code shown on the other device."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Try again"</string>

View file

@ -11,7 +11,7 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.features.login.impl.R
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
@ -61,12 +61,12 @@ class QrCodeIntroViewTest {
}
@Test
fun `on continue button clicked - emits the Continue event`() {
fun `on submit button clicked - emits the Continue event`() {
val eventRecorder = EventsRecorder<QrCodeIntroEvents>()
rule.setQrCodeIntroView(
state = aQrCodeIntroState(eventSink = eventRecorder),
)
rule.clickOn(CommonStrings.action_continue)
rule.clickOn(R.string.screen_qr_code_login_initial_state_button_title)
eventRecorder.assertSingle(QrCodeIntroEvents.Continue)
}

View file

@ -15,6 +15,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -31,7 +33,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
@ -40,6 +41,7 @@ import javax.inject.Inject
class IdentityChangeStatePresenter @Inject constructor(
private val room: MatrixRoom,
private val encryptionService: EncryptionService,
private val featureFlagService: FeatureFlagService,
) : Presenter<IdentityChangeState> {
@Composable
override fun present(): IdentityChangeState {
@ -62,14 +64,18 @@ class IdentityChangeStatePresenter @Inject constructor(
@OptIn(ExperimentalCoroutinesApi::class)
private fun ProduceStateScope<PersistentList<RoomMemberIdentityStateChange>>.observeRoomMemberIdentityStateChange() {
room.syncUpdateFlow
featureFlagService.isFeatureEnabledFlow(FeatureFlags.IdentityPinningViolationNotifications)
.filter { it }
.flatMapLatest {
room.syncUpdateFlow
}
.filter {
// Room cannot become unencrypted, so we can just apply a filter here.
room.isEncrypted
}
.distinctUntilChanged()
.flatMapLatest {
combine(room.identityStateChangesFlow, room.membersStateFlow,) { identityStateChanges, membersState ->
combine(room.identityStateChangesFlow, room.membersStateFlow) { identityStateChanges, membersState ->
identityStateChanges.map { identityStateChange ->
val member = membersState.roomMembers()
?.firstOrNull { roomMember -> roomMember.userId == identityStateChange.userId }

View file

@ -31,6 +31,7 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.draft.ComposerDraftService
import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestionsDataSource
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.utils.TextPillificationHelper

View file

@ -5,20 +5,22 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.messagecomposer
package io.element.android.features.messages.impl.messagecomposer.suggestions
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
data class RoomAliasSuggestion(
val roomAlias: RoomAlias,
val roomSummary: RoomSummary,
val roomId: RoomId,
val roomName: String?,
val roomAvatarUrl: String?,
)
interface RoomAliasSuggestionsDataSource {
@ -32,14 +34,16 @@ class DefaultRoomAliasSuggestionsDataSource @Inject constructor(
override fun getAllRoomAliasSuggestions(): Flow<List<RoomAliasSuggestion>> {
return roomListService
.allRooms
.filteredSummaries
.summaries
.map { roomSummaries ->
roomSummaries
.mapNotNull { roomSummary ->
roomSummary.canonicalAlias?.let { roomAlias ->
roomSummary.info.canonicalAlias?.let { roomAlias ->
RoomAliasSuggestion(
roomAlias = roomAlias,
roomSummary = roomSummary,
roomId = roomSummary.roomId,
roomName = roomSummary.info.name,
roomAvatarUrl = roomSummary.info.avatarUrl,
)
}
}

View file

@ -36,7 +36,6 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.ui.components.aRoomSummaryDetails
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import kotlinx.collections.immutable.ImmutableList
@ -60,7 +59,7 @@ fun SuggestionsPickerView(
when (suggestion) {
is ResolvedSuggestion.AtRoom -> "@room"
is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
is ResolvedSuggestion.Alias -> suggestion.roomSummary.roomId.value
is ResolvedSuggestion.Alias -> suggestion.roomId.value
}
}
) {
@ -96,12 +95,12 @@ private fun SuggestionItemView(
val avatarData = when (suggestion) {
is ResolvedSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
is ResolvedSuggestion.Member -> suggestion.roomMember.getAvatarData(avatarSize)
is ResolvedSuggestion.Alias -> suggestion.roomSummary.getAvatarData(avatarSize)
is ResolvedSuggestion.Alias -> suggestion.getAvatarData(avatarSize)
}
val title = when (suggestion) {
is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title)
is ResolvedSuggestion.Member -> suggestion.roomMember.displayName
is ResolvedSuggestion.Alias -> suggestion.roomSummary.name
is ResolvedSuggestion.Alias -> suggestion.roomName
}
val subtitle = when (suggestion) {
is ResolvedSuggestion.AtRoom -> "@room"
@ -152,11 +151,6 @@ internal fun SuggestionsPickerViewPreview() {
role = RoomMember.Role.USER,
)
val anAlias = remember { RoomAlias("#room:domain.org") }
val roomSummaryDetails = remember {
aRoomSummaryDetails(
name = "My room",
)
}
SuggestionsPickerView(
roomId = RoomId("!room:matrix.org"),
roomName = "Room",
@ -166,8 +160,10 @@ internal fun SuggestionsPickerViewPreview() {
ResolvedSuggestion.Member(roomMember),
ResolvedSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
ResolvedSuggestion.Alias(
anAlias,
roomSummaryDetails,
roomAlias = anAlias,
roomId = RoomId("!room:matrix.org"),
roomName = "My room",
roomAvatarUrl = null,
)
),
onSelectSuggestion = {}

View file

@ -7,7 +7,6 @@
package io.element.android.features.messages.impl.messagecomposer.suggestions
import io.element.android.features.messages.impl.messagecomposer.RoomAliasSuggestion
import io.element.android.libraries.core.data.filterUpTo
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
@ -55,7 +54,14 @@ class SuggestionsProcessor @Inject constructor() {
SuggestionType.Room -> {
roomAliasSuggestions
.filter { it.roomAlias.value.contains(suggestion.text, ignoreCase = true) }
.map { ResolvedSuggestion.Alias(it.roomAlias, it.roomSummary) }
.map {
ResolvedSuggestion.Alias(
roomAlias = it.roomAlias,
roomId = it.roomId,
roomName = it.roomName,
roomAvatarUrl = it.roomAvatarUrl,
)
}
}
SuggestionType.Command,
is SuggestionType.Custom -> {

View file

@ -9,6 +9,9 @@ package io.element.android.features.messages.impl.crypto.identity
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
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.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
@ -65,6 +68,43 @@ class IdentityChangeStatePresenterTest {
}
}
@Test
fun `present - when the room emits identity change, but the feature is disabled, the presenter emits new state`() = runTest {
val room = FakeMatrixRoom(
isEncrypted = true,
)
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.IdentityPinningViolationNotifications.key to false,
)
)
val presenter = createIdentityChangeStatePresenter(
room = room,
featureFlagService = featureFlagService,
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.roomMemberIdentityStateChanges).isEmpty()
room.emitIdentityStateChanges(
listOf(
IdentityStateChange(
userId = A_USER_ID_2,
identityState = IdentityState.PinViolation,
),
)
)
// No item emitted.
expectNoEvents()
// Enable the feature
featureFlagService.setFeatureEnabled(FeatureFlags.IdentityPinningViolationNotifications, true)
val finalItem = awaitItem()
assertThat(finalItem.roomMemberIdentityStateChanges).hasSize(1)
val value = finalItem.roomMemberIdentityStateChanges.first()
assertThat(value.identityRoomMember.userId).isEqualTo(A_USER_ID_2)
assertThat(value.identityState).isEqualTo(IdentityState.PinViolation)
}
}
@Test
fun `present - when the clear room emits identity change, the presenter does not emits new state`() = runTest {
val room = FakeMatrixRoom(isEncrypted = false)
@ -147,10 +187,16 @@ class IdentityChangeStatePresenterTest {
private fun createIdentityChangeStatePresenter(
room: MatrixRoom = FakeMatrixRoom(),
encryptionService: EncryptionService = FakeEncryptionService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.IdentityPinningViolationNotifications.key to true,
)
),
): IdentityChangeStatePresenter {
return IdentityChangeStatePresenter(
room = room,
encryptionService = encryptionService,
featureFlagService = featureFlagService,
)
}
}

View file

@ -9,6 +9,8 @@ package io.element.android.features.messages.impl.messagecomposer
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.messagecomposer.suggestions.DefaultRoomAliasSuggestionsDataSource
import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestion
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.room.aRoomSummary
@ -38,7 +40,9 @@ class DefaultRoomAliasSuggestionsDataSourceTest {
listOf(
RoomAliasSuggestion(
roomAlias = A_ROOM_ALIAS,
roomSummary = aRoomSummaryWithAnAlias
roomId = aRoomSummaryWithAnAlias.roomId,
roomName = aRoomSummaryWithAnAlias.info.name,
roomAvatarUrl = aRoomSummaryWithAnAlias.info.avatarUrl
)
)
)

View file

@ -7,6 +7,8 @@
package io.element.android.features.messages.impl.messagecomposer
import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestion
import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestionsDataSource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow

View file

@ -8,7 +8,6 @@
package io.element.android.features.messages.impl.messagecomposer.suggestions
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.messagecomposer.RoomAliasSuggestion
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
@ -110,13 +109,25 @@ class SuggestionsProcessorTest {
val result = suggestionsProcessor.process(
suggestion = aRoomSuggestion("ALI"),
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf()),
roomAliasSuggestions = listOf(RoomAliasSuggestion(A_ROOM_ALIAS, aRoomSummary)),
roomAliasSuggestions = listOf(
RoomAliasSuggestion(
roomAlias = A_ROOM_ALIAS,
roomId = aRoomSummary.roomId,
roomName = aRoomSummary.info.name,
roomAvatarUrl = aRoomSummary.info.avatarUrl,
)
),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
)
assertThat(result).isEqualTo(
listOf(
ResolvedSuggestion.Alias(A_ROOM_ALIAS, aRoomSummary)
ResolvedSuggestion.Alias(
roomAlias = A_ROOM_ALIAS,
roomId = aRoomSummary.roomId,
roomName = aRoomSummary.info.name,
roomAvatarUrl = aRoomSummary.info.avatarUrl,
)
)
)
}
@ -127,13 +138,25 @@ class SuggestionsProcessorTest {
val result = suggestionsProcessor.process(
suggestion = aRoomSuggestion("ali"),
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf()),
roomAliasSuggestions = listOf(RoomAliasSuggestion(A_ROOM_ALIAS, aRoomSummary)),
roomAliasSuggestions = listOf(
RoomAliasSuggestion(
roomAlias = A_ROOM_ALIAS,
roomId = aRoomSummary.roomId,
roomName = aRoomSummary.info.name,
roomAvatarUrl = aRoomSummary.info.avatarUrl,
)
),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
)
assertThat(result).isEqualTo(
listOf(
ResolvedSuggestion.Alias(A_ROOM_ALIAS, aRoomSummary)
ResolvedSuggestion.Alias(
roomAlias = A_ROOM_ALIAS,
roomId = aRoomSummary.roomId,
roomName = aRoomSummary.info.name,
roomAvatarUrl = aRoomSummary.info.avatarUrl,
)
)
)
}
@ -144,7 +167,14 @@ class SuggestionsProcessorTest {
val result = suggestionsProcessor.process(
suggestion = aRoomSuggestion("tot"),
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf()),
roomAliasSuggestions = listOf(RoomAliasSuggestion(A_ROOM_ALIAS, aRoomSummary)),
roomAliasSuggestions = listOf(
RoomAliasSuggestion(
roomAlias = A_ROOM_ALIAS,
roomId = aRoomSummary.roomId,
roomName = aRoomSummary.info.name,
roomAvatarUrl = aRoomSummary.info.avatarUrl,
)
),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
)

View file

@ -1,3 +1,5 @@
import extension.setupAnvil
/*
* Copyright 2024 New Vector Ltd.
*
@ -7,13 +9,14 @@
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
}
android {
namespace = "io.element.android.features.migration.impl"
}
setupAnvil()
dependencies {
implementation(projects.features.migration.api)
implementation(projects.libraries.architecture)

View file

@ -21,11 +21,12 @@ import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingStateNoSuccess
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.ui.model.getAvatarData
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
@ -40,7 +41,6 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
private val notificationSettingsService: NotificationSettingsService,
@Assisted private val isOneToOne: Boolean,
private val roomListService: RoomListService,
private val matrixClient: MatrixClient,
) : Presenter<EditDefaultNotificationSettingState> {
@AssistedFactory
interface Factory {
@ -57,8 +57,8 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
val changeNotificationSettingAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val roomsWithUserDefinedMode: MutableState<List<RoomSummary>> = remember {
mutableStateOf(listOf())
val roomsWithUserDefinedMode: MutableState<List<EditNotificationSettingRoomInfo>> = remember {
mutableStateOf(emptyList())
}
val localCoroutineScope = rememberCoroutineScope()
@ -106,31 +106,37 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
.launchIn(this)
}
private fun CoroutineScope.observeRoomSummaries(roomsWithUserDefinedMode: MutableState<List<RoomSummary>>) {
private fun CoroutineScope.observeRoomSummaries(roomsWithUserDefinedMode: MutableState<List<EditNotificationSettingRoomInfo>>) {
roomListService.allRooms
.summaries
.onEach {
updateRoomsWithUserDefinedMode(it, roomsWithUserDefinedMode)
.onEach { roomSummaries ->
updateRoomsWithUserDefinedMode(roomSummaries, roomsWithUserDefinedMode)
}
.launchIn(this)
}
private fun CoroutineScope.updateRoomsWithUserDefinedMode(
private suspend fun updateRoomsWithUserDefinedMode(
summaries: List<RoomSummary>,
roomsWithUserDefinedMode: MutableState<List<RoomSummary>>
) = launch {
val roomWithUserDefinedRules: Set<String> = notificationSettingsService.getRoomsWithUserDefinedRules().getOrThrow().toSet()
val sortedSummaries = summaries
.filterIsInstance<RoomSummary>()
.filter {
val room = matrixClient.getRoom(it.roomId) ?: return@filter false
roomWithUserDefinedRules.contains(it.roomId.value) && isOneToOne == room.isOneToOne
roomsWithUserDefinedMode: MutableState<List<EditNotificationSettingRoomInfo>>
) {
val roomWithUserDefinedRules: Set<String> = notificationSettingsService.getRoomsWithUserDefinedRules().getOrDefault(emptyList()).toSet()
roomsWithUserDefinedMode.value = summaries
.filter { roomSummary ->
roomWithUserDefinedRules.contains(roomSummary.roomId.value) && roomSummary.isOneToOne == isOneToOne
}
.map { roomSummary ->
EditNotificationSettingRoomInfo(
roomId = roomSummary.roomId,
name = roomSummary.info.name,
heroesAvatar = roomSummary.info.heroes.map { hero ->
hero.getAvatarData(AvatarSize.CustomRoomNotificationSetting)
}.toImmutableList(),
avatarData = roomSummary.info.getAvatarData(AvatarSize.CustomRoomNotificationSetting),
notificationMode = roomSummary.info.userDefinedNotificationMode,
)
}
// locale sensitive sorting
.sortedWith(compareBy(Collator.getInstance()) { it.name })
roomsWithUserDefinedMode.value = sortedSummaries
.sortedWith(compareBy(Collator.getInstance()) { roomSummary -> roomSummary.name })
}
private fun CoroutineScope.setDefaultNotificationMode(mode: RoomNotificationMode, action: MutableState<AsyncAction<Unit>>) = launch {

View file

@ -9,13 +9,12 @@ package io.element.android.features.preferences.impl.notifications.edit
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.collections.immutable.ImmutableList
data class EditDefaultNotificationSettingState(
val isOneToOne: Boolean,
val mode: RoomNotificationMode?,
val roomsWithUserDefinedMode: ImmutableList<RoomSummary>,
val roomsWithUserDefinedMode: ImmutableList<EditNotificationSettingRoomInfo>,
val changeNotificationSettingAction: AsyncAction<Unit>,
val displayMentionsOnlyDisclaimer: Boolean,
val eventSink: (EditDefaultNotificationSettingStateEvents) -> Unit,

View file

@ -9,9 +9,10 @@ package io.element.android.features.preferences.impl.notifications.edit
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.ui.components.aRoomSummaryDetails
import kotlinx.collections.immutable.persistentListOf
open class EditDefaultNotificationSettingStateProvider : PreviewParameterProvider<EditDefaultNotificationSettingState> {
@ -33,21 +34,25 @@ private fun anEditDefaultNotificationSettingsState(
isOneToOne = isOneToOne,
mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
roomsWithUserDefinedMode = persistentListOf(
aRoomSummary("Room"),
aRoomSummary(null),
anEditNotificationSettingRoomInfo("Room"),
anEditNotificationSettingRoomInfo(null),
),
changeNotificationSettingAction = changeNotificationSettingAction,
displayMentionsOnlyDisclaimer = displayMentionsOnlyDisclaimer,
eventSink = {}
)
private fun aRoomSummary(
private fun anEditNotificationSettingRoomInfo(
name: String?,
) = aRoomSummaryDetails(
) = EditNotificationSettingRoomInfo(
roomId = RoomId("!roomId:domain"),
name = name,
avatarUrl = null,
isDirect = false,
lastMessage = null,
avatarData = AvatarData(
id = "!roomId:domain",
name = name,
url = null,
size = AvatarSize.CustomRoomNotificationSetting,
),
heroesAvatar = persistentListOf(),
notificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
)

View file

@ -16,7 +16,6 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.CompositeAvatar
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
@ -27,9 +26,7 @@ import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toPersistentList
/**
* A view that allows a user to edit the default notification setting for rooms. This can be set separately
@ -80,7 +77,7 @@ fun EditDefaultNotificationSettingView(
if (state.roomsWithUserDefinedMode.isNotEmpty()) {
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_edit_custom_settings_section_title)) {
state.roomsWithUserDefinedMode.forEach { summary ->
val subtitle = when (summary.userDefinedNotificationMode) {
val subtitle = when (summary.notificationMode) {
RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_notification_settings_edit_mode_all_messages)
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> {
stringResource(id = R.string.screen_notification_settings_edit_mode_mentions_and_keywords)
@ -101,10 +98,8 @@ fun EditDefaultNotificationSettingView(
},
leadingContent = ListItemContent.Custom {
CompositeAvatar(
avatarData = summary.getAvatarData(size = AvatarSize.CustomRoomNotificationSetting),
heroes = summary.heroes.map { user ->
user.getAvatarData(size = AvatarSize.CustomRoomNotificationSetting)
}.toPersistentList()
avatarData = summary.avatarData,
heroes = summary.heroesAvatar,
)
},
onClick = {

View file

@ -0,0 +1,21 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.preferences.impl.notifications.edit
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import kotlinx.collections.immutable.ImmutableList
data class EditNotificationSettingRoomInfo(
val roomId: RoomId,
val name: String?,
val heroesAvatar: ImmutableList<AvatarData>,
val avatarData: AvatarData,
val notificationMode: RoomNotificationMode?
)

View file

@ -14,11 +14,8 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingPresenter
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingStateEvents
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.tests.testutils.awaitLastSequentialItem
@ -49,24 +46,20 @@ class EditDefaultNotificationSettingsPresenterTest {
@Test
fun `present - ensure list of rooms with user defined mode`() = runTest {
val room = FakeMatrixRoom()
val notificationSettingsService = FakeNotificationSettingsService(
initialRoomMode = RoomNotificationMode.ALL_MESSAGES,
initialRoomModeIsDefault = false
)
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService).apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val roomListService = FakeRoomListService()
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService, matrixClient)
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
roomListService.postAllRooms(listOf(aRoomSummary(notificationMode = RoomNotificationMode.ALL_MESSAGES)))
roomListService.postAllRooms(listOf(aRoomSummary(userDefinedNotificationMode = RoomNotificationMode.ALL_MESSAGES)))
val loadedState = consumeItemsUntilPredicate { state ->
state.roomsWithUserDefinedMode.any { it.userDefinedNotificationMode == RoomNotificationMode.ALL_MESSAGES }
state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.ALL_MESSAGES }
}.last()
assertThat(loadedState.roomsWithUserDefinedMode.any { it.userDefinedNotificationMode == RoomNotificationMode.ALL_MESSAGES }).isTrue()
assertThat(loadedState.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.ALL_MESSAGES }).isTrue()
}
}
@ -121,13 +114,11 @@ class EditDefaultNotificationSettingsPresenterTest {
private fun createEditDefaultNotificationSettingPresenter(
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
roomListService: FakeRoomListService = FakeRoomListService(),
matrixClient: FakeMatrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
): EditDefaultNotificationSettingPresenter {
return EditDefaultNotificationSettingPresenter(
notificationSettingsService = notificationSettingsService,
isOneToOne = false,
roomListService = roomListService,
matrixClient = matrixClient
)
}
}

View file

@ -9,7 +9,6 @@ import extension.setupAnvil
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
id("kotlin-parcelize")
}

View file

@ -14,6 +14,7 @@ import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormat
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toInviteSender
@ -24,34 +25,35 @@ class RoomListRoomSummaryFactory @Inject constructor(
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
private val roomLastMessageFormatter: RoomLastMessageFormatter,
) {
fun create(details: RoomSummary): RoomListRoomSummary {
val avatarData = details.getAvatarData(size = AvatarSize.RoomListItem)
fun create(roomSummary: RoomSummary): RoomListRoomSummary {
val roomInfo = roomSummary.info
val avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomListItem)
return RoomListRoomSummary(
id = details.roomId.value,
roomId = details.roomId,
name = details.name,
numberOfUnreadMessages = details.numUnreadMessages,
numberOfUnreadMentions = details.numUnreadMentions,
numberOfUnreadNotifications = details.numUnreadNotifications,
isMarkedUnread = details.isMarkedUnread,
timestamp = lastMessageTimestampFormatter.format(details.lastMessageTimestamp),
lastMessage = details.lastMessage?.let { message ->
roomLastMessageFormatter.format(message.event, details.isDm)
id = roomSummary.roomId.value,
roomId = roomSummary.roomId,
name = roomInfo.name,
numberOfUnreadMessages = roomInfo.numUnreadMessages,
numberOfUnreadMentions = roomInfo.numUnreadMentions,
numberOfUnreadNotifications = roomInfo.numUnreadNotifications,
isMarkedUnread = roomInfo.isMarkedUnread,
timestamp = lastMessageTimestampFormatter.format(roomSummary.lastMessageTimestamp),
lastMessage = roomSummary.lastMessage?.let { message ->
roomLastMessageFormatter.format(message.event, roomInfo.isDm)
}.orEmpty(),
avatarData = avatarData,
userDefinedNotificationMode = details.userDefinedNotificationMode,
hasRoomCall = details.hasRoomCall,
isDirect = details.isDirect,
isFavorite = details.isFavorite,
inviteSender = details.inviter?.toInviteSender(),
isDm = details.isDm,
canonicalAlias = details.canonicalAlias,
displayType = if (details.currentUserMembership == CurrentUserMembership.INVITED) {
userDefinedNotificationMode = roomInfo.userDefinedNotificationMode,
hasRoomCall = roomInfo.hasRoomCall,
isDirect = roomInfo.isDirect,
isFavorite = roomInfo.isFavorite,
inviteSender = roomInfo.inviter?.toInviteSender(),
isDm = roomInfo.isDm,
canonicalAlias = roomInfo.canonicalAlias,
displayType = if (roomInfo.currentUserMembership == CurrentUserMembership.INVITED) {
RoomSummaryDisplayType.INVITE
} else {
RoomSummaryDisplayType.ROOM
},
heroes = details.heroes.map { user ->
heroes = roomInfo.heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomListItem)
}.toImmutableList(),
)

View file

@ -22,9 +22,9 @@ data class RoomListRoomSummary(
val roomId: RoomId,
val name: String?,
val canonicalAlias: RoomAlias?,
val numberOfUnreadMessages: Int,
val numberOfUnreadMentions: Int,
val numberOfUnreadNotifications: Int,
val numberOfUnreadMessages: Long,
val numberOfUnreadMentions: Long,
val numberOfUnreadNotifications: Long,
val isMarkedUnread: Boolean,
val timestamp: String?,
val lastMessage: CharSequence?,

View file

@ -119,9 +119,9 @@ internal fun anInviteSender(
internal fun aRoomListRoomSummary(
id: String = "!roomId:domain",
name: String? = "Room name",
numberOfUnreadMessages: Int = 0,
numberOfUnreadMentions: Int = 0,
numberOfUnreadNotifications: Int = 0,
numberOfUnreadMessages: Long = 0,
numberOfUnreadMentions: Long = 0,
numberOfUnreadNotifications: Long = 0,
isMarkedUnread: Boolean = false,
lastMessage: String? = "Last message",
timestamp: String? = lastMessage?.let { "88:88" },

View file

@ -396,7 +396,7 @@ class RoomListPresenterTest {
val notificationSettingsService = FakeNotificationSettingsService()
val roomListService = FakeRoomListService()
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
roomListService.postAllRooms(listOf(aRoomSummary(notificationMode = userDefinedMode)))
roomListService.postAllRooms(listOf(aRoomSummary(userDefinedNotificationMode = userDefinedMode)))
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
notificationSettingsService = notificationSettingsService

View file

@ -76,9 +76,9 @@ class RoomListRoomSummaryTest {
}
internal fun createRoomListRoomSummary(
numberOfUnreadMentions: Int = 0,
numberOfUnreadMessages: Int = 0,
numberOfUnreadNotifications: Int = 0,
numberOfUnreadMentions: Long = 0,
numberOfUnreadMessages: Long = 0,
numberOfUnreadNotifications: Long = 0,
isMarkedUnread: Boolean = false,
userDefinedNotificationMode: RoomNotificationMode? = null,
isFavorite: Boolean = false,

View file

@ -31,7 +31,6 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@Composable
@ -50,7 +49,7 @@ fun ResetIdentityRootView(
buttons = {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = CommonStrings.action_continue),
text = stringResource(id = R.string.screen_encryption_reset_action_continue_reset),
onClick = { state.eventSink(ResetIdentityRootEvent.Continue) },
destructive = true,
)
@ -98,9 +97,9 @@ private fun Content() {
iconComposable = {
Icon(
modifier = Modifier.size(20.dp),
imageVector = CompoundIcons.Close(),
imageVector = CompoundIcons.Info(),
contentDescription = null,
tint = ElementTheme.colors.iconCriticalPrimary,
tint = ElementTheme.colors.iconSecondary,
)
},
),
@ -109,9 +108,9 @@ private fun Content() {
iconComposable = {
Icon(
modifier = Modifier.size(20.dp),
imageVector = CompoundIcons.Close(),
imageVector = CompoundIcons.Info(),
contentDescription = null,
tint = ElementTheme.colors.iconCriticalPrimary,
tint = ElementTheme.colors.iconSecondary,
)
},
),

View file

@ -18,7 +18,7 @@
<string name="screen_create_new_recovery_key_title">"Reset the encryption for your account using another device"</string>
<string name="screen_encryption_reset_action_continue_reset">"Continue reset"</string>
<string name="screen_encryption_reset_bullet_1">"Your account details, contacts, preferences, and chat list will be kept"</string>
<string name="screen_encryption_reset_bullet_2">"You will lose your existing message history unless it is stored on another device"</string>
<string name="screen_encryption_reset_bullet_2">"You will lose any message history thats stored only on the server"</string>
<string name="screen_encryption_reset_bullet_3">"You will need to verify all your existing devices and contacts again"</string>
<string name="screen_encryption_reset_footer">"Only reset your identity if you dont have access to another signed-in device and youve lost your recovery key."</string>
<string name="screen_encryption_reset_title">"Can\'t confirm? Youll need to reset your identity."</string>

View file

@ -60,7 +60,7 @@ class ResetIdentityRootViewTest {
ResetIdentityRootState(displayConfirmationDialog = false, eventSink = eventsRecorder),
)
rule.clickOn(CommonStrings.action_continue)
rule.clickOn(R.string.screen_encryption_reset_action_continue_reset)
eventsRecorder.assertSingle(ResetIdentityRootEvent.Continue)
}

View file

@ -23,6 +23,8 @@ android {
setupAnvil()
dependencies {
implementation(projects.appconfig)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)

View file

@ -18,9 +18,11 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.appconfig.LearnMoreConfig
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.logout.api.util.onSuccessLogout
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
@ -36,6 +38,10 @@ class VerifySelfSessionNode @AssistedInject constructor(
showDeviceVerifiedScreen = inputs<VerifySessionEntryPoint.Params>().showDeviceVerifiedScreen,
)
private fun onLearnMoreClick(activity: Activity, dark: Boolean) {
activity.openUrlInChromeCustomTab(null, dark, LearnMoreConfig.ENCRYPTION_URL)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@ -44,6 +50,9 @@ class VerifySelfSessionNode @AssistedInject constructor(
VerifySelfSessionView(
state = state,
modifier = modifier,
onLearnMoreClick = {
onLearnMoreClick(activity, isDark)
},
onEnterRecoveryKey = callback::onEnterRecoveryKey,
onResetKey = callback::onResetKey,
onFinish = callback::onDone,

View file

@ -23,6 +23,8 @@ data class VerifySelfSessionState(
@Stable
sealed interface VerificationStep {
data object Loading : VerificationStep
// FIXME canEnterRecoveryKey value is never read.
data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : VerificationStep
data object Canceled : VerificationStep
data object AwaitingOtherDeviceResponse : VerificationStep

View file

@ -9,13 +9,13 @@ package io.element.android.features.verifysession.impl
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -64,6 +64,7 @@ import io.element.android.features.verifysession.impl.VerifySelfSessionState.Ver
@Composable
fun VerifySelfSessionView(
state: VerifySelfSessionState,
onLearnMoreClick: () -> Unit,
onEnterRecoveryKey: () -> Unit,
onResetKey: () -> Unit,
onFinish: () -> Unit,
@ -140,7 +141,10 @@ fun VerifySelfSessionView(
)
}
) {
Content(flowState = verificationFlowStep)
Content(
flowState = verificationFlowStep,
onLearnMoreClick = onLearnMoreClick,
)
}
}
@ -203,38 +207,68 @@ private fun HeaderContent(verificationFlowStep: FlowStep) {
}
@Composable
private fun Content(flowState: FlowStep) {
Column(Modifier.fillMaxHeight(), verticalArrangement = Arrangement.Center) {
if (flowState is FlowStep.Verifying) {
private fun Content(
flowState: FlowStep,
onLearnMoreClick: () -> Unit,
) {
when (flowState) {
is VerifySelfSessionState.VerificationStep.Initial -> {
ContentInitial(onLearnMoreClick)
}
is FlowStep.Verifying -> {
ContentVerifying(flowState)
}
else -> Unit
}
}
@Composable
private fun ContentInitial(
onLearnMoreClick: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
Text(
modifier = Modifier
.clickable { onLearnMoreClick() }
.padding(vertical = 4.dp, horizontal = 16.dp),
text = stringResource(CommonStrings.action_learn_more),
style = ElementTheme.typography.fontBodyLgMedium
)
}
}
@Composable
private fun ContentVerifying(verificationFlowStep: FlowStep.Verifying) {
when (verificationFlowStep.data) {
is SessionVerificationData.Decimals -> {
val text = verificationFlowStep.data.decimals.joinToString(separator = " - ") { it.toString() }
Text(
modifier = Modifier.fillMaxWidth(),
text = text,
style = ElementTheme.typography.fontHeadingLgBold,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
)
}
is SessionVerificationData.Emojis -> {
// We want each row to have up to 4 emojis
val rows = verificationFlowStep.data.emojis.chunked(4)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(40.dp),
) {
rows.forEach { emojis ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
for (emoji in emojis) {
EmojiItemView(emoji = emoji, modifier = Modifier.widthIn(max = 60.dp))
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
when (verificationFlowStep.data) {
is SessionVerificationData.Decimals -> {
val text = verificationFlowStep.data.decimals.joinToString(separator = " - ") { it.toString() }
Text(
modifier = Modifier.fillMaxWidth(),
text = text,
style = ElementTheme.typography.fontHeadingLgBold,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
)
}
is SessionVerificationData.Emojis -> {
// We want each row to have up to 4 emojis
val rows = verificationFlowStep.data.emojis.chunked(4)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(40.dp),
) {
rows.forEach { emojis ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
for (emoji in emojis) {
EmojiItemView(emoji = emoji, modifier = Modifier.widthIn(max = 60.dp))
}
}
}
}
@ -292,14 +326,14 @@ private fun BottomMenu(
text = stringResource(R.string.screen_identity_use_another_device),
onClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
)
OutlinedButton(
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_enter_recovery_key),
onClick = onEnterRecoveryKey,
)
}
// This option should always be displayed
TextButton(
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_confirmation_cannot_confirm),
onClick = onResetKey,
@ -402,6 +436,7 @@ private fun BottomMenu(
internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionStateProvider::class) state: VerifySelfSessionState) = ElementPreview {
VerifySelfSessionView(
state = state,
onLearnMoreClick = {},
onEnterRecoveryKey = {},
onResetKey = {},
onFinish = {},

View file

@ -343,6 +343,7 @@ class VerifySelfSessionPresenterTest {
skipItems(1)
val initialItem = awaitItem()
initialItem.eventSink(VerifySelfSessionViewEvents.SignOut)
assertThat(awaitItem().signOutAction.isLoading()).isTrue()
val finalItem = awaitItem()
assertThat(finalItem.signOutAction.isSuccess()).isTrue()
assertThat(finalItem.signOutAction.dataOrNull()).isEqualTo("aUrl")

View file

@ -146,6 +146,22 @@ class VerifySelfSessionViewTest {
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on learn more invokes the expected callback`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
eventSink = eventsRecorder
),
onLearnMoreClick = callback,
)
rule.clickOn(CommonStrings.action_learn_more)
}
}
@Test
fun `clicking on they match emits the expected event`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
@ -222,6 +238,7 @@ class VerifySelfSessionViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setVerifySelfSessionView(
state: VerifySelfSessionState,
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
onEnterRecoveryKey: () -> Unit = EnsureNeverCalled(),
onFinished: () -> Unit = EnsureNeverCalled(),
onResetKey: () -> Unit = EnsureNeverCalled(),
@ -230,6 +247,7 @@ class VerifySelfSessionViewTest {
setContent {
VerifySelfSessionView(
state = state,
onLearnMoreClick = onLearnMoreClick,
onEnterRecoveryKey = onEnterRecoveryKey,
onFinish = onFinished,
onResetKey = onResetKey,

View file

@ -4,9 +4,9 @@
[versions]
# Project
android_gradle_plugin = "8.7.0"
kotlin = "1.9.25"
kotlin = "2.0.20"
kotlinpoetKsp = "1.18.1"
ksp = "1.9.25-1.0.20"
ksp = "2.0.20-1.0.25"
firebaseAppDistribution = "5.0.0"
# AndroidX
@ -25,7 +25,7 @@ media3 = "1.4.1"
camera = "1.3.4"
# Compose
compose_bom = "2024.09.02"
compose_bom = "2024.09.03"
composecompiler = "1.5.15"
# Coroutines
@ -40,7 +40,7 @@ test_core = "1.6.1"
#other
coil = "2.7.0"
datetime = "0.6.0"
dependencyAnalysis = "2.1.3"
dependencyAnalysis = "2.1.4"
serialization_json = "1.6.3"
showkase = "1.0.3"
appyx = "1.4.0"
@ -62,6 +62,7 @@ kover = "0.8.3"
[libraries]
# Project
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }
compose_compiler_plugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" }
# https://developer.android.com/studio/write/java8-support#library-desugaring-versions
android_desugar = "com.android.tools:desugar_jdk_libs:2.1.2"
anvil_gradle_plugin = { module = "dev.zacsweers.anvil:gradle-plugin", version.ref = "anvil" }
@ -103,7 +104,7 @@ androidx_activity_activity = { module = "androidx.activity:activity", version.re
androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
androidx_startup = "androidx.startup:startup-runtime:1.2.0"
androidx_preference = "androidx.preference:preference:1.2.1"
androidx_webkit = "androidx.webkit:webkit:1.12.0"
androidx_webkit = "androidx.webkit:webkit:1.12.1"
androidx_compose_bom = { module = "androidx.compose:compose-bom", version.ref = "compose_bom" }
androidx_compose_material3 = { module = "androidx.compose.material3:material3" }
@ -143,7 +144,7 @@ test_corektx = { module = "androidx.test:core-ktx", version.ref = "test_core" }
test_arch_core = "androidx.arch.core:core-testing:2.2.0"
test_junit = "junit:junit:4.13.2"
test_runner = "androidx.test:runner:1.6.2"
test_mockk = "io.mockk:mockk:1.13.12"
test_mockk = "io.mockk:mockk:1.13.13"
test_konsist = "com.lemonappdev:konsist:0.16.1"
test_turbine = "app.cash.turbine:turbine:1.1.0"
test_truth = "com.google.truth:truth:1.4.4"
@ -189,13 +190,13 @@ kotlinpoet = "com.squareup:kotlinpoet:1.18.1"
zxing_cpp = "io.github.zxing-cpp:android:2.2.0"
# Analytics
posthog = "com.posthog:posthog-android:3.8.0"
posthog = "com.posthog:posthog-android:3.8.1"
sentry = "io.sentry:sentry-android:7.14.0"
# main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.25.0"
# Emojibase
matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.1.3"
matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.3.3"
sigpwned_emoji4j = "com.sigpwned:emoji4j-core:15.1.2"
# Di
@ -238,3 +239,4 @@ firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.
knit = { id = "org.jetbrains.kotlinx.knit", version = "0.5.0" }
sonarqube = "org.sonarqube:5.1.0.4882"
licensee = "app.cash.licensee:1.11.0"
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
@ -24,6 +25,7 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup
fun LinearProgressIndicator(
progress: () -> Float,
modifier: Modifier = Modifier,
gapSize: Dp = 0.dp,
color: Color = ProgressIndicatorDefaults.linearColor,
trackColor: Color = ProgressIndicatorDefaults.linearTrackColor,
strokeCap: StrokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
@ -31,9 +33,11 @@ fun LinearProgressIndicator(
androidx.compose.material3.LinearProgressIndicator(
modifier = modifier,
progress = progress,
gapSize = gapSize,
color = color,
trackColor = trackColor,
strokeCap = strokeCap,
drawStopIndicator = {},
)
}
@ -41,6 +45,7 @@ fun LinearProgressIndicator(
fun LinearProgressIndicator(
modifier: Modifier = Modifier,
color: Color = ProgressIndicatorDefaults.linearColor,
gapSize: Dp = 0.dp,
trackColor: Color = ProgressIndicatorDefaults.linearTrackColor,
strokeCap: StrokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
) {
@ -49,14 +54,17 @@ fun LinearProgressIndicator(
androidx.compose.material3.LinearProgressIndicator(
modifier = modifier,
progress = { 0.75F },
gapSize = gapSize,
color = color,
trackColor = trackColor,
strokeCap = strokeCap,
drawStopIndicator = {},
)
} else {
androidx.compose.material3.LinearProgressIndicator(
modifier = modifier,
color = color,
gapSize = gapSize,
trackColor = trackColor,
strokeCap = strokeCap,
)

View file

@ -7,7 +7,6 @@
package io.element.android.libraries.designsystem.theme.components.bottomsheet
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.foundation.ExperimentalFoundationApi
@ -296,13 +295,9 @@ internal object AnchoredDraggableDefaults {
/**
* The default animation used by [AnchoredDraggableState].
*/
@get:ExperimentalMaterial3Api
@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
@ExperimentalMaterial3Api
val SnapAnimationSpec = SpringSpec<Float>()
@get:ExperimentalMaterial3Api
@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
@ExperimentalMaterial3Api
val DecayAnimationSpec = exponentialDecay<Float>()
}

View file

@ -9,7 +9,6 @@ package io.element.android.libraries.featureflag.api
import io.element.android.appconfig.OnBoardingConfig
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
/**
* To enable or disable a FeatureFlags, change the `defaultValue` value.
@ -93,13 +92,7 @@ enum class FeatureFlags(
key = "feature.qrCodeLogin",
title = "Enable login using QR code",
description = "Allow the user to login using the QR code flow",
defaultValue = { buildMeta ->
when (buildMeta.buildType) {
// TODO remove once the feature is ready to publish
BuildType.RELEASE -> false
else -> OnBoardingConfig.CAN_LOGIN_WITH_QR_CODE
}
},
defaultValue = { OnBoardingConfig.CAN_LOGIN_WITH_QR_CODE },
isFinished = false,
),
IncomingShare(
@ -132,4 +125,17 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
IdentityPinningViolationNotifications(
key = "feature.identityPinningViolationNotifications",
title = "Identity pinning violation notifications",
description = null,
defaultValue = { buildMeta ->
when (buildMeta.buildType) {
// Do not enable this feature in release builds
BuildType.RELEASE -> false
else -> true
}
},
isFinished = false,
),
}

View file

@ -38,6 +38,8 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import java.io.Closeable
import java.util.Optional
@ -94,7 +96,13 @@ interface MatrixClient : Closeable {
suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result<String?>
suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result<String>
fun roomMembershipObserver(): RoomMembershipObserver
fun getRoomInfoFlow(roomId: RoomId): Flow<Optional<MatrixRoomInfo>>
/**
* Get a room summary flow for a given room ID or alias.
* The flow will emit a new value whenever the room summary is updated.
* The flow will emit Optional.empty item if the room is not found.
*/
fun getRoomSummaryFlow(roomIdOrAlias: RoomIdOrAlias): Flow<Optional<RoomSummary>>
fun isMe(userId: UserId?) = userId == sessionId
@ -142,3 +150,14 @@ interface MatrixClient : Closeable {
fun canDeactivateAccount(): Boolean
suspend fun deactivateAccount(password: String, eraseData: Boolean): Result<Unit>
}
/**
* Get a room info flow for a given room ID or alias.
* The flow will emit a new value whenever the room info is updated.
* The flow will emit Optional.empty item if the room is not found.
*/
fun MatrixClient.getRoomInfoFlow(roomIdOrAlias: RoomIdOrAlias): Flow<Optional<MatrixRoomInfo>> {
return getRoomSummaryFlow(roomIdOrAlias)
.map { roomSummary -> roomSummary.map { it.info } }
.distinctUntilChanged()
}

View file

@ -33,6 +33,13 @@ data class MatrixRoomInfo(
val canonicalAlias: RoomAlias?,
val alternativeAliases: ImmutableList<RoomAlias>,
val currentUserMembership: CurrentUserMembership,
/**
* Member who invited the current user to a room that's in the invited
* state.
*
* Can be missing if the room membership invite event is missing from the
* store.
*/
val inviter: RoomMember?,
val activeMembersCount: Long,
val invitedMembersCount: Long,
@ -43,7 +50,26 @@ data class MatrixRoomInfo(
val userDefinedNotificationMode: RoomNotificationMode?,
val hasRoomCall: Boolean,
val activeRoomCallParticipants: ImmutableList<UserId>,
val isMarkedUnread: Boolean,
/**
* "Interesting" messages received in that room, independently of the
* notification settings.
*/
val numUnreadMessages: Long,
/**
* Events that will notify the user, according to their
* notification settings.
*/
val numUnreadNotifications: Long,
/**
* Events causing mentions/highlights for the user, according to their
* notification settings.
*/
val numUnreadMentions: Long,
val heroes: ImmutableList<MatrixUser>,
val pinnedEventIds: ImmutableList<EventId>,
val creator: UserId?,
)
) {
val aliases: List<RoomAlias>
get() = listOfNotNull(canonicalAlias) + alternativeAliases
}

View file

@ -7,35 +7,14 @@
package io.element.android.libraries.matrix.api.roomlist
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.message.RoomMessage
import io.element.android.libraries.matrix.api.user.MatrixUser
data class RoomSummary(
val roomId: RoomId,
val name: String?,
val canonicalAlias: RoomAlias?,
val alternativeAliases: List<RoomAlias>,
val isDirect: Boolean,
val avatarUrl: String?,
val info: MatrixRoomInfo,
val lastMessage: RoomMessage?,
val numUnreadMessages: Int,
val numUnreadMentions: Int,
val numUnreadNotifications: Int,
val isMarkedUnread: Boolean,
val inviter: RoomMember?,
val userDefinedNotificationMode: RoomNotificationMode?,
val hasRoomCall: Boolean,
val isDm: Boolean,
val isFavorite: Boolean,
val currentUserMembership: CurrentUserMembership,
val heroes: List<MatrixUser>,
) {
val roomId = info.id
val lastMessageTimestamp = lastMessage?.originServerTs
val aliases: List<RoomAlias>
get() = listOfNotNull(canonicalAlias) + alternativeAliases
val isOneToOne get() = info.activeMembersCount == 2L
}

View file

@ -32,7 +32,6 @@ import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.InvitedRoom
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.preview.RoomPreview
@ -84,8 +83,8 @@ import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@ -105,8 +104,8 @@ import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import java.io.File
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration
import kotlin.time.Duration.Companion.INFINITE
import kotlin.time.Duration.Companion.seconds
import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParameters
import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset
@ -262,21 +261,14 @@ class RustMatrixClient(
* @param timeout the timeout to wait for the room to be available
* @throws TimeoutCancellationException if the room is not available after the timeout
*/
private suspend fun awaitJoinedRoom(roomIdOrAlias: RoomIdOrAlias, timeout: Duration): RoomSummary {
val predicate: (List<RoomSummary>) -> Boolean = when (roomIdOrAlias) {
is RoomIdOrAlias.Alias -> { roomSummaries: List<RoomSummary> ->
val found = roomSummaries.find { it.aliases.contains(roomIdOrAlias.roomAlias) }
found != null && found.currentUserMembership == CurrentUserMembership.JOINED
}
is RoomIdOrAlias.Id -> { roomSummaries: List<RoomSummary> ->
val found = roomSummaries.find { it.roomId == roomIdOrAlias.roomId }
found != null && found.currentUserMembership == CurrentUserMembership.JOINED
}
}
private suspend fun awaitJoinedRoom(
roomIdOrAlias: RoomIdOrAlias,
timeout: Duration
): RoomSummary {
return withTimeout(timeout) {
roomListService.allRooms.summaries
.filter(predicate)
.first()
getRoomSummaryFlow(roomIdOrAlias)
.mapNotNull { optionalRoomSummary -> optionalRoomSummary.getOrNull() }
.filter { roomSummary -> roomSummary.info.currentUserMembership == CurrentUserMembership.JOINED }
.first()
// Ensure that the room is ready
.also { client.awaitRoomRemoteEcho(it.roomId.value) }
@ -568,20 +560,21 @@ class RustMatrixClient(
override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver
override fun getRoomInfoFlow(roomId: RoomId): Flow<Optional<MatrixRoomInfo>> {
return flow {
var room = getRoom(roomId)
if (room == null) {
emit(Optional.empty())
awaitJoinedRoom(roomId.toRoomIdOrAlias(), INFINITE)
room = getRoom(roomId)
override fun getRoomSummaryFlow(roomIdOrAlias: RoomIdOrAlias): Flow<Optional<RoomSummary>> {
val predicate: (RoomSummary) -> Boolean = when (roomIdOrAlias) {
is RoomIdOrAlias.Alias -> { roomSummary ->
roomSummary.info.aliases.contains(roomIdOrAlias.roomAlias)
}
room?.use {
room.roomInfoFlow
.map { roomInfo -> Optional.of(roomInfo) }
.collect(this)
is RoomIdOrAlias.Id -> { roomSummary ->
roomSummary.roomId == roomIdOrAlias.roomId
}
}.distinctUntilChanged()
}
return roomListService.allRooms.summaries
.map { roomSummaries ->
val roomSummary = roomSummaries.firstOrNull(predicate)
Optional.ofNullable(roomSummary)
}
.distinctUntilChanged()
}
override suspend fun setAllSendQueuesEnabled(enabled: Boolean) = withContext(sessionDispatcher) {

View file

@ -53,6 +53,10 @@ class MatrixRoomInfoMapper {
activeRoomCallParticipants = it.activeRoomCallParticipants.map(::UserId).toImmutableList(),
heroes = it.elementHeroes().toImmutableList(),
pinnedEventIds = it.pinnedEventIds.map(::EventId).toImmutableList(),
isMarkedUnread = it.isMarkedUnread,
numUnreadMessages = it.numUnreadMessages.toLong(),
numUnreadMentions = it.numUnreadMentions.toLong(),
numUnreadNotifications = it.numUnreadNotifications.toLong(),
)
}
}

View file

@ -31,7 +31,7 @@ internal class RoomListFactory(
private val innerRoomListService: RoomListService,
private val sessionCoroutineScope: CoroutineScope,
) {
private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory()
private val roomSummaryDetailsFactory: RoomSummaryFactory = RoomSummaryFactory()
/**
* Creates a room list that can be used to load more rooms and filter them dynamically.

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
@ -17,19 +18,19 @@ val RoomListFilter.predicate
is RoomListFilter.Any -> { _: RoomSummary -> true }
RoomListFilter.None -> { _: RoomSummary -> false }
RoomListFilter.Category.Group -> { roomSummary: RoomSummary ->
!roomSummary.isDm && !roomSummary.isInvited()
!roomSummary.info.isDm && !roomSummary.isInvited()
}
RoomListFilter.Category.People -> { roomSummary: RoomSummary ->
roomSummary.isDm && !roomSummary.isInvited()
roomSummary.info.isDm && !roomSummary.isInvited()
}
RoomListFilter.Favorite -> { roomSummary: RoomSummary ->
roomSummary.isFavorite && !roomSummary.isInvited()
roomSummary.info.isFavorite && !roomSummary.isInvited()
}
RoomListFilter.Unread -> { roomSummary: RoomSummary ->
!roomSummary.isInvited() && (roomSummary.numUnreadNotifications > 0 || roomSummary.isMarkedUnread)
!roomSummary.isInvited() && (roomSummary.info.numUnreadNotifications > 0 || roomSummary.info.isMarkedUnread)
}
is RoomListFilter.NormalizedMatchRoomName -> { roomSummary: RoomSummary ->
roomSummary.name.orEmpty().contains(pattern, ignoreCase = true)
roomSummary.info.name.orEmpty().contains(pattern, ignoreCase = true)
}
RoomListFilter.Invite -> { roomSummary: RoomSummary ->
roomSummary.isInvited()
@ -50,4 +51,4 @@ fun List<RoomSummary>.filter(filter: RoomListFilter): List<RoomSummary> {
}
}
private fun RoomSummary.isInvited() = currentUserMembership == CurrentUserMembership.INVITED
private fun RoomSummary.isInvited() = info.currentUserMembership == CurrentUserMembership.INVITED

View file

@ -1,51 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.impl.notificationsettings.RoomNotificationSettingsMapper
import io.element.android.libraries.matrix.impl.room.elementHeroes
import io.element.android.libraries.matrix.impl.room.map
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.message.RoomMessageFactory
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.use
class RoomSummaryDetailsFactory(
private val roomMessageFactory: RoomMessageFactory = RoomMessageFactory(),
) {
suspend fun create(roomListItem: RoomListItem): RoomSummary {
val roomInfo = roomListItem.roomInfo()
val latestRoomMessage = roomListItem.latestEvent().use { event ->
roomMessageFactory.create(event)
}
return RoomSummary(
roomId = RoomId(roomInfo.id),
name = roomInfo.displayName,
canonicalAlias = roomInfo.canonicalAlias?.let(::RoomAlias),
alternativeAliases = roomInfo.alternativeAliases.map(::RoomAlias),
isDirect = roomInfo.isDirect,
avatarUrl = roomInfo.avatarUrl,
numUnreadMentions = roomInfo.numUnreadMentions.toInt(),
numUnreadMessages = roomInfo.numUnreadMessages.toInt(),
numUnreadNotifications = roomInfo.numUnreadNotifications.toInt(),
isMarkedUnread = roomInfo.isMarkedUnread,
lastMessage = latestRoomMessage,
inviter = roomInfo.inviter?.let(RoomMemberMapper::map),
userDefinedNotificationMode = roomInfo.cachedUserDefinedNotificationMode?.let(RoomNotificationSettingsMapper::mapMode),
hasRoomCall = roomInfo.hasRoomCall,
isDm = isDm(isDirect = roomInfo.isDirect, activeMembersCount = roomInfo.activeMembersCount.toInt()),
isFavorite = roomInfo.isFavourite,
currentUserMembership = roomInfo.membership.map(),
heroes = roomInfo.elementHeroes(),
)
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.impl.room.MatrixRoomInfoMapper
import io.element.android.libraries.matrix.impl.room.message.RoomMessageFactory
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.use
class RoomSummaryFactory(
private val roomMessageFactory: RoomMessageFactory = RoomMessageFactory(),
private val roomInfoMapper: MatrixRoomInfoMapper = MatrixRoomInfoMapper(),
) {
suspend fun create(roomListItem: RoomListItem): RoomSummary {
val roomInfo = roomListItem.roomInfo().let(roomInfoMapper::map)
val latestRoomMessage = roomListItem.latestEvent().use { event ->
roomMessageFactory.create(event)
}
return RoomSummary(
info = roomInfo,
lastMessage = latestRoomMessage,
)
}
}

View file

@ -23,9 +23,8 @@ class RoomSummaryListProcessor(
private val roomSummaries: MutableSharedFlow<List<RoomSummary>>,
private val roomListService: RoomListServiceInterface,
private val coroutineContext: CoroutineContext,
private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
private val roomSummaryDetailsFactory: RoomSummaryFactory = RoomSummaryFactory(),
) {
private val roomSummariesByIdentifier = HashMap<String, RoomSummary>()
private val mutex = Mutex()
suspend fun postUpdate(updates: List<RoomListEntriesUpdate>) {
@ -40,7 +39,7 @@ class RoomSummaryListProcessor(
suspend fun rebuildRoomSummaries() {
updateRoomSummaries {
forEachIndexed { i, summary ->
val result = buildAndCacheRoomSummaryForIdentifier(summary.roomId.value)
val result = buildRoomSummaryForIdentifier(summary.roomId.value)
if (result != null) {
this[i] = result
}
@ -97,23 +96,17 @@ class RoomSummaryListProcessor(
}
private suspend fun buildSummaryForRoomListEntry(entry: RoomListItem): RoomSummary {
return buildAndCacheRoomSummaryForRoomListItem(entry)
return buildRoomSummaryForRoomListItem(entry)
}
private suspend fun buildAndCacheRoomSummaryForIdentifier(identifier: String): RoomSummary? {
val builtRoomSummary = roomListService.roomOrNull(identifier)?.use { roomListItem ->
buildAndCacheRoomSummaryForRoomListItem(roomListItem)
private suspend fun buildRoomSummaryForIdentifier(identifier: String): RoomSummary? {
return roomListService.roomOrNull(identifier)?.use { roomListItem ->
buildRoomSummaryForRoomListItem(roomListItem)
}
if (builtRoomSummary == null) {
roomSummariesByIdentifier.remove(identifier)
}
return builtRoomSummary
}
private suspend fun buildAndCacheRoomSummaryForRoomListItem(roomListItem: RoomListItem): RoomSummary {
val builtRoomSummary = roomSummaryDetailsFactory.create(roomListItem = roomListItem)
roomSummariesByIdentifier[builtRoomSummary.roomId.value] = builtRoomSummary
return builtRoomSummary
private suspend fun buildRoomSummaryForRoomListItem(roomListItem: RoomListItem): RoomSummary {
return roomSummaryDetailsFactory.create(roomListItem = roomListItem)
}
private suspend fun updateRoomSummaries(block: suspend MutableList<RoomSummary>.() -> Unit) = withContext(coroutineContext) {

View file

@ -105,6 +105,10 @@ class MatrixRoomInfoMapperTest {
).toImmutableList(),
pinnedEventIds = listOf(AN_EVENT_ID).toPersistentList(),
creator = A_USER_ID,
isMarkedUnread = false,
numUnreadMessages = 12L,
numUnreadNotifications = 13L,
numUnreadMentions = 14L,
)
)
}
@ -174,6 +178,10 @@ class MatrixRoomInfoMapperTest {
heroes = emptyList<MatrixUser>().toImmutableList(),
pinnedEventIds = emptyList<EventId>().toPersistentList(),
creator = null,
isMarkedUnread = true,
numUnreadMessages = 12L,
numUnreadNotifications = 13L,
numUnreadMentions = 14L,
)
)
}

View file

@ -19,7 +19,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SERVER_LIST
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@ -29,7 +29,7 @@ import org.junit.Test
class DefaultJoinRoomTest {
@Test
fun `when using roomId and there is no server names, the classic join room API is used`() = runTest {
val roomSummary = aRoomSummaryFilled()
val roomSummary = aRoomSummary()
val joinRoomLambda = lambdaRecorder { _: RoomId -> Result.success(roomSummary) }
val joinRoomByIdOrAliasLambda = lambdaRecorder { _: RoomIdOrAlias, _: List<String> -> Result.success(roomSummary) }
val roomResult = FakeMatrixRoom()
@ -64,7 +64,7 @@ class DefaultJoinRoomTest {
@Test
fun `when using roomId and server names are available, joinRoomByIdOrAlias API is used`() = runTest {
val roomSummary = aRoomSummaryFilled()
val roomSummary = aRoomSummary()
val joinRoomLambda = lambdaRecorder { _: RoomId -> Result.success(roomSummary) }
val joinRoomByIdOrAliasLambda = lambdaRecorder { _: RoomIdOrAlias, _: List<String> -> Result.success(roomSummary) }
val roomResult = FakeMatrixRoom()
@ -100,7 +100,7 @@ class DefaultJoinRoomTest {
@Test
fun `when using roomAlias, joinRoomByIdOrAlias API is used`() = runTest {
val roomSummary = aRoomSummaryFilled()
val roomSummary = aRoomSummary()
val joinRoomLambda = lambdaRecorder { _: RoomId -> Result.success(roomSummary) }
val joinRoomByIdOrAliasLambda = lambdaRecorder { _: RoomIdOrAlias, _: List<String> -> Result.success(roomSummary) }
val roomResult = FakeMatrixRoom()

View file

@ -16,10 +16,11 @@ import org.junit.Test
class RoomListFilterTest {
private val regularRoom = aRoomSummary(
isDm = false
isDirect = false,
)
private val dmRoom = aRoomSummary(
isDm = true
isDirect = true,
activeMembersCount = 2
)
private val favoriteRoom = aRoomSummary(
isFavorite = true

View file

@ -15,7 +15,6 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID_3
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
@ -40,7 +39,7 @@ class RoomSummaryListProcessorTest {
@Test
fun `PushBack adds a new entry at the end of the list`() = runTest {
summaries.value = listOf(aRoomSummaryFilled())
summaries.value = listOf(aRoomSummary())
val processor = createProcessor()
processor.postUpdate(listOf(RoomListEntriesUpdate.PushBack(FakeRustRoomListItem(A_ROOM_ID_2))))
@ -50,7 +49,7 @@ class RoomSummaryListProcessorTest {
@Test
fun `PushFront inserts a new entry at the start of the list`() = runTest {
summaries.value = listOf(aRoomSummaryFilled())
summaries.value = listOf(aRoomSummary())
val processor = createProcessor()
processor.postUpdate(listOf(RoomListEntriesUpdate.PushFront(FakeRustRoomListItem(A_ROOM_ID_2))))
@ -60,7 +59,7 @@ class RoomSummaryListProcessorTest {
@Test
fun `Set replaces an entry at some index`() = runTest {
summaries.value = listOf(aRoomSummaryFilled())
summaries.value = listOf(aRoomSummary())
val processor = createProcessor()
val index = 0
@ -72,7 +71,7 @@ class RoomSummaryListProcessorTest {
@Test
fun `Insert inserts a new entry at the provided index`() = runTest {
summaries.value = listOf(aRoomSummaryFilled())
summaries.value = listOf(aRoomSummary())
val processor = createProcessor()
val index = 0
@ -84,7 +83,10 @@ class RoomSummaryListProcessorTest {
@Test
fun `Remove removes an entry at some index`() = runTest {
summaries.value = listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(A_ROOM_ID_2))
summaries.value = listOf(
aRoomSummary(roomId = A_ROOM_ID),
aRoomSummary(A_ROOM_ID_2)
)
val processor = createProcessor()
val index = 0
@ -96,7 +98,10 @@ class RoomSummaryListProcessorTest {
@Test
fun `PopBack removes an entry at the end of the list`() = runTest {
summaries.value = listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(A_ROOM_ID_2))
summaries.value = listOf(
aRoomSummary(roomId = A_ROOM_ID),
aRoomSummary(A_ROOM_ID_2)
)
val processor = createProcessor()
val index = 0
@ -108,7 +113,10 @@ class RoomSummaryListProcessorTest {
@Test
fun `PopFront removes an entry at the start of the list`() = runTest {
summaries.value = listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(A_ROOM_ID_2))
summaries.value = listOf(
aRoomSummary(roomId = A_ROOM_ID),
aRoomSummary(A_ROOM_ID_2)
)
val processor = createProcessor()
val index = 0
@ -120,7 +128,10 @@ class RoomSummaryListProcessorTest {
@Test
fun `Clear removes all the entries`() = runTest {
summaries.value = listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(A_ROOM_ID_2))
summaries.value = listOf(
aRoomSummary(roomId = A_ROOM_ID),
aRoomSummary(A_ROOM_ID_2)
)
val processor = createProcessor()
processor.postUpdate(listOf(RoomListEntriesUpdate.Clear))
@ -130,7 +141,10 @@ class RoomSummaryListProcessorTest {
@Test
fun `Truncate removes all entries after the provided length`() = runTest {
summaries.value = listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(A_ROOM_ID_2))
summaries.value = listOf(
aRoomSummary(roomId = A_ROOM_ID),
aRoomSummary(A_ROOM_ID_2)
)
val processor = createProcessor()
val index = 0
@ -142,7 +156,10 @@ class RoomSummaryListProcessorTest {
@Test
fun `Reset removes all entries and add the provided ones`() = runTest {
summaries.value = listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(A_ROOM_ID_2))
summaries.value = listOf(
aRoomSummary(roomId = A_ROOM_ID),
aRoomSummary(A_ROOM_ID_2)
)
val processor = createProcessor()
val index = 0
@ -156,6 +173,6 @@ class RoomSummaryListProcessorTest {
summaries,
FakeRustRoomListService(),
coroutineContext = StandardTestDispatcher(testScheduler),
roomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
roomSummaryDetailsFactory = RoomSummaryFactory(),
)
}

View file

@ -24,7 +24,6 @@ import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.InvitedRoom
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.preview.RoomPreview
@ -118,8 +117,8 @@ class FakeMatrixClient(
var knockRoomLambda: (RoomId) -> Result<Unit> = {
Result.success(Unit)
}
var getRoomInfoFlowLambda = { _: RoomId ->
flowOf<Optional<MatrixRoomInfo>>(Optional.empty())
var getRoomSummaryFlowLambda = { _: RoomIdOrAlias ->
flowOf<Optional<RoomSummary>>(Optional.empty())
}
var logoutLambda: (Boolean, Boolean) -> String? = { _, _ ->
null
@ -316,7 +315,7 @@ class FakeMatrixClient(
return Result.success(visitedRoomsId)
}
override fun getRoomInfoFlow(roomId: RoomId) = getRoomInfoFlowLambda(roomId)
override fun getRoomSummaryFlow(roomIdOrAlias: RoomIdOrAlias) = getRoomSummaryFlowLambda(roomIdOrAlias)
var setAllSendQueuesEnabledLambda = lambdaRecorder(ensureNeverCalled = true) { _: Boolean ->
// no-op

View file

@ -24,7 +24,6 @@ import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
@ -32,7 +31,6 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.location.AssetType
@ -40,21 +38,15 @@ import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerL
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@ -527,62 +519,6 @@ class FakeMatrixRoom(
}
}
fun aRoomInfo(
id: RoomId = A_ROOM_ID,
name: String? = A_ROOM_NAME,
rawName: String? = name,
topic: String? = "A topic",
avatarUrl: String? = AN_AVATAR_URL,
isDirect: Boolean = false,
isPublic: Boolean = true,
isSpace: Boolean = false,
isTombstoned: Boolean = false,
isFavorite: Boolean = false,
canonicalAlias: RoomAlias? = null,
alternativeAliases: List<RoomAlias> = emptyList(),
currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
inviter: RoomMember? = null,
activeMembersCount: Long = 1,
invitedMembersCount: Long = 0,
joinedMembersCount: Long = 1,
highlightCount: Long = 0,
notificationCount: Long = 0,
userDefinedNotificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false,
userPowerLevels: ImmutableMap<UserId, Long> = persistentMapOf(),
activeRoomCallParticipants: List<UserId> = emptyList(),
heroes: List<MatrixUser> = emptyList(),
pinnedEventIds: List<EventId> = emptyList(),
roomCreator: UserId? = null,
) = MatrixRoomInfo(
id = id,
name = name,
rawName = rawName,
topic = topic,
avatarUrl = avatarUrl,
isDirect = isDirect,
isPublic = isPublic,
isSpace = isSpace,
isTombstoned = isTombstoned,
isFavorite = isFavorite,
canonicalAlias = canonicalAlias,
alternativeAliases = alternativeAliases.toImmutableList(),
currentUserMembership = currentUserMembership,
inviter = inviter,
activeMembersCount = activeMembersCount,
invitedMembersCount = invitedMembersCount,
joinedMembersCount = joinedMembersCount,
highlightCount = highlightCount,
notificationCount = notificationCount,
userDefinedNotificationMode = userDefinedNotificationMode,
hasRoomCall = hasRoomCall,
userPowerLevels = userPowerLevels,
activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(),
heroes = heroes.toImmutableList(),
pinnedEventIds = pinnedEventIds.toImmutableList(),
creator = roomCreator,
)
fun defaultRoomPowerLevels() = MatrixRoomPowerLevels(
ban = 50,
invite = 0,

View file

@ -0,0 +1,90 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.matrix.test.room
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_ROOM_RAW_NAME
import io.element.android.libraries.matrix.test.A_ROOM_TOPIC
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
fun aRoomInfo(
id: RoomId = A_ROOM_ID,
name: String? = A_ROOM_NAME,
rawName: String? = A_ROOM_RAW_NAME,
topic: String? = A_ROOM_TOPIC,
avatarUrl: String? = AN_AVATAR_URL,
isDirect: Boolean = false,
isPublic: Boolean = true,
isSpace: Boolean = false,
isTombstoned: Boolean = false,
isFavorite: Boolean = false,
canonicalAlias: RoomAlias? = null,
alternativeAliases: List<RoomAlias> = emptyList(),
currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
inviter: RoomMember? = null,
activeMembersCount: Long = 1,
invitedMembersCount: Long = 0,
joinedMembersCount: Long = 1,
highlightCount: Long = 0,
notificationCount: Long = 0,
userDefinedNotificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false,
userPowerLevels: ImmutableMap<UserId, Long> = persistentMapOf(),
activeRoomCallParticipants: List<UserId> = emptyList(),
heroes: List<MatrixUser> = emptyList(),
pinnedEventIds: List<EventId> = emptyList(),
roomCreator: UserId? = null,
isMarkedUnread: Boolean = false,
numUnreadMessages: Long = 0,
numUnreadNotifications: Long = 0,
numUnreadMentions: Long = 0,
) = MatrixRoomInfo(
id = id,
name = name,
rawName = rawName,
topic = topic,
avatarUrl = avatarUrl,
isDirect = isDirect,
isPublic = isPublic,
isSpace = isSpace,
isTombstoned = isTombstoned,
isFavorite = isFavorite,
canonicalAlias = canonicalAlias,
alternativeAliases = alternativeAliases.toImmutableList(),
currentUserMembership = currentUserMembership,
inviter = inviter,
activeMembersCount = activeMembersCount,
invitedMembersCount = invitedMembersCount,
joinedMembersCount = joinedMembersCount,
highlightCount = highlightCount,
notificationCount = notificationCount,
userDefinedNotificationMode = userDefinedNotificationMode,
hasRoomCall = hasRoomCall,
userPowerLevels = userPowerLevels,
activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(),
heroes = heroes.toImmutableList(),
pinnedEventIds = pinnedEventIds.toImmutableList(),
creator = roomCreator,
isMarkedUnread = isMarkedUnread,
numUnreadMessages = numUnreadMessages,
numUnreadNotifications = numUnreadNotifications,
numUnreadMentions = numUnreadMentions,
)

View file

@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.message.RoomMessage
@ -21,69 +22,88 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_ROOM_RAW_NAME
import io.element.android.libraries.matrix.test.A_ROOM_TOPIC
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toPersistentList
fun aRoomSummaryFilled(
roomId: RoomId = A_ROOM_ID,
name: String = A_ROOM_NAME,
isDirect: Boolean = false,
avatarUrl: String? = null,
fun aRoomSummary(
info: MatrixRoomInfo = aRoomInfo(),
lastMessage: RoomMessage? = aRoomMessage(),
numUnreadMentions: Int = 0,
numUnreadMessages: Int = 0,
notificationMode: RoomNotificationMode? = null,
currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
) = aRoomSummary(
roomId = roomId,
name = name,
isDirect = isDirect,
avatarUrl = avatarUrl,
) = RoomSummary(
info = info,
lastMessage = lastMessage,
numUnreadMentions = numUnreadMentions,
numUnreadMessages = numUnreadMessages,
notificationMode = notificationMode,
currentUserMembership = currentUserMembership,
)
fun aRoomSummary(
roomId: RoomId = A_ROOM_ID,
name: String? = A_ROOM_NAME,
isDirect: Boolean = false,
rawName: String? = A_ROOM_RAW_NAME,
topic: String? = A_ROOM_TOPIC,
avatarUrl: String? = null,
lastMessage: RoomMessage? = aRoomMessage(),
numUnreadMentions: Int = 0,
numUnreadMessages: Int = 0,
numUnreadNotifications: Int = 0,
isMarkedUnread: Boolean = false,
notificationMode: RoomNotificationMode? = null,
inviter: RoomMember? = null,
isDirect: Boolean = false,
isPublic: Boolean = true,
isSpace: Boolean = false,
isTombstoned: Boolean = false,
isFavorite: Boolean = false,
canonicalAlias: RoomAlias? = null,
alternativeAliases: List<RoomAlias> = emptyList(),
hasRoomCall: Boolean = false,
isDm: Boolean = false,
isFavorite: Boolean = false,
currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
inviter: RoomMember? = null,
activeMembersCount: Long = 1,
invitedMembersCount: Long = 0,
joinedMembersCount: Long = 1,
highlightCount: Long = 0,
notificationCount: Long = 0,
userDefinedNotificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false,
userPowerLevels: ImmutableMap<UserId, Long> = persistentMapOf(),
activeRoomCallParticipants: List<UserId> = emptyList(),
heroes: List<MatrixUser> = emptyList(),
pinnedEventIds: List<EventId> = emptyList(),
roomCreator: UserId? = null,
isMarkedUnread: Boolean = false,
numUnreadMessages: Long = 0,
numUnreadNotifications: Long = 0,
numUnreadMentions: Long = 0,
lastMessage: RoomMessage? = aRoomMessage(),
) = RoomSummary(
roomId = roomId,
name = name,
isDirect = isDirect,
avatarUrl = avatarUrl,
info = MatrixRoomInfo(
id = roomId,
name = name,
rawName = rawName,
topic = topic,
avatarUrl = avatarUrl,
isDirect = isDirect,
isPublic = isPublic,
isSpace = isSpace,
isTombstoned = isTombstoned,
isFavorite = isFavorite,
canonicalAlias = canonicalAlias,
alternativeAliases = alternativeAliases.toPersistentList(),
currentUserMembership = currentUserMembership,
inviter = inviter,
activeMembersCount = activeMembersCount,
invitedMembersCount = invitedMembersCount,
joinedMembersCount = joinedMembersCount,
userPowerLevels = userPowerLevels,
highlightCount = highlightCount,
notificationCount = notificationCount,
userDefinedNotificationMode = userDefinedNotificationMode,
hasRoomCall = hasRoomCall,
activeRoomCallParticipants = activeRoomCallParticipants.toPersistentList(),
heroes = heroes.toPersistentList(),
pinnedEventIds = pinnedEventIds.toPersistentList(),
creator = roomCreator,
isMarkedUnread = isMarkedUnread,
numUnreadMessages = numUnreadMessages,
numUnreadNotifications = numUnreadNotifications,
numUnreadMentions = numUnreadMentions,
),
lastMessage = lastMessage,
numUnreadMentions = numUnreadMentions,
numUnreadMessages = numUnreadMessages,
numUnreadNotifications = numUnreadNotifications,
isMarkedUnread = isMarkedUnread,
userDefinedNotificationMode = notificationMode,
inviter = inviter,
canonicalAlias = canonicalAlias,
alternativeAliases = alternativeAliases,
hasRoomCall = hasRoomCall,
isDm = isDm,
isFavorite = isFavorite,
currentUserMembership = currentUserMembership,
heroes = heroes,
)
fun aRoomMessage(

View file

@ -1,66 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.components
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.message.RoomMessage
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.user.MatrixUser
open class RoomSummaryDetailsProvider : PreviewParameterProvider<RoomSummary> {
override val values: Sequence<RoomSummary>
get() = sequenceOf(
aRoomSummaryDetails(),
aRoomSummaryDetails(name = null),
)
}
fun aRoomSummaryDetails(
roomId: RoomId = RoomId("!room:domain"),
name: String? = "roomName",
canonicalAlias: RoomAlias? = null,
alternativeAliases: List<RoomAlias> = emptyList(),
isDirect: Boolean = true,
avatarUrl: String? = null,
lastMessage: RoomMessage? = null,
inviter: RoomMember? = null,
notificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false,
isDm: Boolean = false,
numUnreadMentions: Int = 0,
numUnreadMessages: Int = 0,
numUnreadNotifications: Int = 0,
isMarkedUnread: Boolean = false,
isFavorite: Boolean = false,
currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
heroes: List<MatrixUser> = emptyList(),
) = RoomSummary(
roomId = roomId,
name = name,
canonicalAlias = canonicalAlias,
alternativeAliases = alternativeAliases,
isDirect = isDirect,
avatarUrl = avatarUrl,
lastMessage = lastMessage,
inviter = inviter,
userDefinedNotificationMode = notificationMode,
hasRoomCall = hasRoomCall,
isDm = isDm,
numUnreadMentions = numUnreadMentions,
numUnreadMessages = numUnreadMessages,
numUnreadNotifications = numUnreadNotifications,
isMarkedUnread = isMarkedUnread,
isFavorite = isFavorite,
currentUserMembership = currentUserMembership,
heroes = heroes,
)

View file

@ -0,0 +1,39 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.components
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
class SelectRoomInfoProvider : PreviewParameterProvider<SelectRoomInfo> {
override val values: Sequence<SelectRoomInfo>
get() = sequenceOf(
aSelectRoomInfo(roomId = RoomId("!room1:domain")),
aSelectRoomInfo(roomId = RoomId("!room2:domain"), name = "Room with a name"),
aSelectRoomInfo(roomId = RoomId("!room3:domain"), name = "Room with a name and alias", canonicalAlias = RoomAlias("#alias:domain")),
)
}
fun aSelectRoomInfo(
roomId: RoomId,
name: String? = null,
canonicalAlias: RoomAlias? = null,
avatarUrl: String? = null,
heroes: ImmutableList<MatrixUser> = persistentListOf(),
) = SelectRoomInfo(
roomId = roomId,
name = name,
canonicalAlias = canonicalAlias,
avatarUrl = avatarUrl,
heroes = heroes,
)

View file

@ -34,15 +34,15 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
@Composable
fun SelectedRoom(
roomSummary: RoomSummary,
onRemoveRoom: (RoomSummary) -> Unit,
roomInfo: SelectRoomInfo,
onRemoveRoom: (SelectRoomInfo) -> Unit,
modifier: Modifier = Modifier,
) {
Box(
@ -53,14 +53,12 @@ fun SelectedRoom(
horizontalAlignment = Alignment.CenterHorizontally,
) {
CompositeAvatar(
avatarData = roomSummary.getAvatarData(size = AvatarSize.SelectedRoom),
heroes = roomSummary.heroes.map { user ->
user.getAvatarData(size = AvatarSize.SelectedRoom)
}.toImmutableList()
avatarData = roomInfo.getAvatarData(AvatarSize.SelectedRoom),
heroes = roomInfo.heroes.map { it.getAvatarData(AvatarSize.SelectedRoom) }.toImmutableList(),
)
Text(
// If name is null, we do not have space to render "No room name", so just use `#` here.
text = roomSummary.name ?: "#",
text = roomInfo.name ?: "#",
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyLarge,
@ -69,14 +67,14 @@ fun SelectedRoom(
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clip(CircleShape)
.size(20.dp)
.align(Alignment.TopEnd)
.clickable(
indication = ripple(),
interactionSource = remember { MutableInteractionSource() },
onClick = { onRemoveRoom(roomSummary) }
),
.clip(CircleShape)
.size(20.dp)
.align(Alignment.TopEnd)
.clickable(
indication = ripple(),
interactionSource = remember { MutableInteractionSource() },
onClick = { onRemoveRoom(roomInfo) }
),
) {
Icon(
imageVector = CompoundIcons.Close(),
@ -91,10 +89,10 @@ fun SelectedRoom(
@PreviewsDayNight
@Composable
internal fun SelectedRoomPreview(
@PreviewParameter(RoomSummaryDetailsProvider::class) roomSummary: RoomSummary
@PreviewParameter(SelectRoomInfoProvider::class) roomInfo: SelectRoomInfo
) = ElementPreview {
SelectedRoom(
roomSummary = roomSummary,
roomInfo = roomInfo,
onRemoveRoom = {},
)
}

View file

@ -7,7 +7,6 @@
package io.element.android.libraries.matrix.ui.media
import android.content.Context
import coil.ImageLoader
import coil.fetch.Fetcher
import coil.request.Options
@ -15,7 +14,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.MatrixClient
internal class AvatarDataFetcherFactory(
private val context: Context,
private val client: MatrixClient
) : Fetcher.Factory<AvatarData> {
override fun create(
@ -24,7 +22,6 @@ internal class AvatarDataFetcherFactory(
imageLoader: ImageLoader
): Fetcher {
return CoilMediaFetcher(
scalingFunction = { context.resources.displayMetrics.density * it },
mediaLoader = client.mediaLoader,
mediaData = data.toMediaRequestData(),
options = options

View file

@ -9,11 +9,25 @@ package io.element.android.libraries.matrix.ui.media
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.media.MediaSource
import kotlin.math.roundToLong
/**
* The size in pixel of the thumbnail to generate for the avatar.
* This is not the size of the avatar displayed in the UI but the size to get from the servers.
* Servers SHOULD produce thumbnails with the following dimensions and methods:
*
* 32x32, crop
* 96x96, crop
* 320x240, scale
* 640x480, scale
* 800x600, scale
*
* Let's always use the same size so coil caching works properly.
*/
const val AVATAR_THUMBNAIL_SIZE_IN_PIXEL = 240L
internal fun AvatarData.toMediaRequestData(): MediaRequestData {
return MediaRequestData(
source = url?.let { MediaSource(it) },
kind = MediaRequestData.Kind.Thumbnail(size.dp.value.roundToLong())
kind = MediaRequestData.Kind.Thumbnail(AVATAR_THUMBNAIL_SIZE_IN_PIXEL)
)
}

View file

@ -20,10 +20,8 @@ import okio.Buffer
import okio.Path.Companion.toOkioPath
import timber.log.Timber
import java.nio.ByteBuffer
import kotlin.math.roundToLong
internal class CoilMediaFetcher(
private val scalingFunction: (Float) -> Float,
private val mediaLoader: MatrixMediaLoader,
private val mediaData: MediaRequestData,
private val options: Options
@ -74,8 +72,8 @@ internal class CoilMediaFetcher(
private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail, options: Options): FetchResult? {
return mediaLoader.loadMediaThumbnail(
source = mediaSource,
width = scalingFunction(kind.width.toFloat()).roundToLong(),
height = scalingFunction(kind.height.toFloat()).roundToLong(),
width = kind.width,
height = kind.height,
).map { byteArray ->
byteArray.asSourceResult(options)
}.onFailure {

View file

@ -43,8 +43,8 @@ class DefaultLoggedInImageLoaderFactory @Inject constructor(
}
add(AvatarDataKeyer())
add(MediaRequestDataKeyer())
add(AvatarDataFetcherFactory(context, matrixClient))
add(MediaRequestDataFetcherFactory(context, matrixClient))
add(AvatarDataFetcherFactory(matrixClient))
add(MediaRequestDataFetcherFactory(matrixClient))
}
.build()
}

View file

@ -7,14 +7,12 @@
package io.element.android.libraries.matrix.ui.media
import android.content.Context
import coil.ImageLoader
import coil.fetch.Fetcher
import coil.request.Options
import io.element.android.libraries.matrix.api.MatrixClient
internal class MediaRequestDataFetcherFactory(
private val context: Context,
private val client: MatrixClient
) : Fetcher.Factory<MediaRequestData> {
override fun create(
@ -23,7 +21,6 @@ internal class MediaRequestDataFetcherFactory(
imageLoader: ImageLoader
): Fetcher {
return CoilMediaFetcher(
scalingFunction = { context.resources.displayMetrics.density * it },
mediaLoader = client.mediaLoader,
mediaData = data,
options = options

View file

@ -9,10 +9,10 @@ package io.element.android.libraries.matrix.ui.model
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
fun RoomSummary.getAvatarData(size: AvatarSize) = AvatarData(
id = roomId.value,
fun MatrixRoomInfo.getAvatarData(size: AvatarSize) = AvatarData(
id = id.value,
name = name,
url = avatarUrl,
size = size,

View file

@ -0,0 +1,39 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.model
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class SelectRoomInfo(
val roomId: RoomId,
val name: String?,
val canonicalAlias: RoomAlias?,
val avatarUrl: String?,
val heroes: ImmutableList<MatrixUser>,
) {
fun getAvatarData(size: AvatarSize) = AvatarData(
id = roomId.value,
name = name,
url = avatarUrl,
size = size,
)
}
fun RoomSummary.toSelectRoomInfo() = SelectRoomInfo(
roomId = roomId,
name = info.name,
avatarUrl = info.avatarUrl,
heroes = info.heroes,
canonicalAlias = info.canonicalAlias,
)

View file

@ -1,3 +1,5 @@
import extension.setupAnvil
/*
* Copyright 2023, 2024 New Vector Ltd.
*
@ -7,9 +9,10 @@
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
}
setupAnvil()
android {
namespace = "io.element.android.libraries.mediapickers.api"

View file

@ -1,3 +1,5 @@
import extension.setupAnvil
/*
* Copyright 2023, 2024 New Vector Ltd.
*
@ -7,9 +9,10 @@
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
}
setupAnvil()
android {
namespace = "io.element.android.libraries.mediapickers.test"

View file

@ -19,6 +19,7 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
@ -45,7 +46,7 @@ class DefaultNotificationBitmapLoader @Inject constructor(
private suspend fun loadRoomBitmap(path: String, imageLoader: ImageLoader): Bitmap? {
return try {
val imageRequest = ImageRequest.Builder(context)
.data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(1024)))
.data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(AVATAR_THUMBNAIL_SIZE_IN_PIXEL)))
.transformations(CircleCropTransformation())
.build()
val result = imageLoader.execute(imageRequest)
@ -73,7 +74,7 @@ class DefaultNotificationBitmapLoader @Inject constructor(
private suspend fun loadUserIcon(path: String, imageLoader: ImageLoader): IconCompat? {
return try {
val imageRequest = ImageRequest.Builder(context)
.data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(1024)))
.data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(AVATAR_THUMBNAIL_SIZE_IN_PIXEL)))
.transformations(CircleCropTransformation())
.build()
val result = imageLoader.execute(imageRequest)

View file

@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_TIMESTAMP
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.push.impl.notifications.factories.createNotificationCreator
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
@ -84,7 +85,7 @@ class DefaultRoomGroupMessageCreatorTest {
expectedCoilRequests = listOf(
MediaRequestData(
source = MediaSource(url = A_ROOM_AVATAR),
kind = MediaRequestData.Kind.Thumbnail(1024)
kind = MediaRequestData.Kind.Thumbnail(AVATAR_THUMBNAIL_SIZE_IN_PIXEL)
)
)
)
@ -98,15 +99,15 @@ class DefaultRoomGroupMessageCreatorTest {
expectedCoilRequests = listOf(
MediaRequestData(
source = MediaSource(url = A_USER_AVATAR_1),
kind = MediaRequestData.Kind.Thumbnail(1024)
kind = MediaRequestData.Kind.Thumbnail(AVATAR_THUMBNAIL_SIZE_IN_PIXEL)
),
MediaRequestData(
source = MediaSource(url = A_USER_AVATAR_2),
kind = MediaRequestData.Kind.Thumbnail(1024)
kind = MediaRequestData.Kind.Thumbnail(AVATAR_THUMBNAIL_SIZE_IN_PIXEL)
),
MediaRequestData(
source = MediaSource(url = A_ROOM_AVATAR),
kind = MediaRequestData.Kind.Thumbnail(1024)
kind = MediaRequestData.Kind.Thumbnail(AVATAR_THUMBNAIL_SIZE_IN_PIXEL)
),
)
)

View file

@ -10,7 +10,7 @@ package io.element.android.libraries.pushproviders.unifiedpush
import io.element.android.tests.testutils.lambda.lambdaError
class FakeUnifiedPushNewGatewayHandler(
private val handleResult: suspend (String, String, String) -> Result<Unit> = { _, _, _ -> lambdaError() },
private val handleResult: (String, String, String) -> Result<Unit> = { _, _, _ -> lambdaError() },
) : UnifiedPushNewGatewayHandler {
override suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String): Result<Unit> {
return handleResult(endpoint, pushGateway, clientSecret)

View file

@ -7,10 +7,10 @@
package io.element.android.libraries.roomselect.impl
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
sealed interface RoomSelectEvents {
data class SetSelectedRoom(val room: RoomSummary) : RoomSelectEvents
data class SetSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvents
// TODO remove to restore multi-selection
data object RemoveSelectedRoom : RoomSelectEvents

View file

@ -9,6 +9,7 @@ package io.element.android.libraries.roomselect.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -20,8 +21,9 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -36,7 +38,7 @@ class RoomSelectPresenter @AssistedInject constructor(
@Composable
override fun present(): RoomSelectState {
var selectedRooms by remember { mutableStateOf(persistentListOf<RoomSummary>()) }
var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) }
var searchQuery by remember { mutableStateOf("") }
var isSearchActive by remember { mutableStateOf(false) }
@ -48,9 +50,9 @@ class RoomSelectPresenter @AssistedInject constructor(
dataSource.setSearchQuery(searchQuery)
}
val roomSummaryDetailsList by dataSource.roomSummaries.collectAsState(initial = persistentListOf())
val roomSummaryDetailsList by dataSource.roomInfoList.collectAsState(initial = persistentListOf())
val searchResults by remember {
val searchResults by remember<State<SearchBarResultState<ImmutableList<SelectRoomInfo>>>> {
derivedStateOf {
when {
roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList())

View file

@ -12,8 +12,9 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.coroutineScope
@ -38,11 +39,12 @@ class RoomSelectSearchDataSource @Inject constructor(
source = RoomList.Source.All,
)
val roomSummaries: Flow<PersistentList<RoomSummary>> = roomList.filteredSummaries
val roomInfoList: Flow<PersistentList<SelectRoomInfo>> = roomList.filteredSummaries
.map { roomSummaries ->
roomSummaries
.filter { it.currentUserMembership == CurrentUserMembership.JOINED }
.filter { it.info.currentUserMembership == CurrentUserMembership.JOINED }
.distinctBy { it.roomId } // This should be removed once we're sure no duplicate Rooms can be received
.map { roomSummary -> roomSummary.toSelectRoomInfo() }
.toPersistentList()
}
.flowOn(coroutineDispatchers.computation)

View file

@ -8,15 +8,15 @@
package io.element.android.libraries.roomselect.impl
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.collections.immutable.ImmutableList
data class RoomSelectState(
val mode: RoomSelectMode,
val resultState: SearchBarResultState<ImmutableList<RoomSummary>>,
val resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>>,
val query: String,
val isSearchActive: Boolean,
val selectedRooms: ImmutableList<RoomSummary>,
val selectedRooms: ImmutableList<SelectRoomInfo>,
val eventSink: (RoomSelectEvents) -> Unit
)

View file

@ -11,8 +11,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.ui.components.aRoomSummaryDetails
import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -32,7 +32,7 @@ open class RoomSelectStateProvider : PreviewParameterProvider<RoomSelectState> {
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
query = "Test",
isSearchActive = true,
selectedRooms = persistentListOf(aRoomSummaryDetails(roomId = RoomId("!room2:domain")))
selectedRooms = aRoomSelectRoomList().subList(0, 1),
),
aRoomSelectState(
mode = RoomSelectMode.Share,
@ -43,10 +43,10 @@ open class RoomSelectStateProvider : PreviewParameterProvider<RoomSelectState> {
private fun aRoomSelectState(
mode: RoomSelectMode = RoomSelectMode.Forward,
resultState: SearchBarResultState<ImmutableList<RoomSummary>> = SearchBarResultState.Initial(),
resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial(),
query: String = "",
isSearchActive: Boolean = false,
selectedRooms: ImmutableList<RoomSummary> = persistentListOf(),
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
) = RoomSelectState(
mode = mode,
resultState = resultState,
@ -57,14 +57,16 @@ private fun aRoomSelectState(
)
private fun aRoomSelectRoomList() = persistentListOf(
aRoomSummaryDetails(),
aRoomSummaryDetails(
aSelectRoomInfo(
roomId = RoomId("!room1:domain"),
name = "Room with name",
),
aSelectRoomInfo(
roomId = RoomId("!room2:domain"),
name = "Room with alias",
canonicalAlias = RoomAlias("#alias:example.org"),
),
aRoomSummaryDetails(
aSelectRoomInfo(
roomId = RoomId("!room3:domain"),
name = null,
),
)

View file

@ -47,8 +47,8 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.ui.components.SelectedRoom
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.libraries.ui.strings.CommonStrings
@ -65,13 +65,13 @@ fun RoomSelectView(
modifier: Modifier = Modifier,
) {
@Suppress("UNUSED_PARAMETER")
fun onRoomRemoved(roomSummary: RoomSummary) {
fun onRoomRemoved(roomInfo: SelectRoomInfo) {
// TODO toggle selection when multi-selection is enabled
state.eventSink(RoomSelectEvents.RemoveSelectedRoom)
}
@Composable
fun SelectedRoomsHelper(isForwarding: Boolean, selectedRooms: ImmutableList<RoomSummary>) {
fun SelectedRoomsHelper(isForwarding: Boolean, selectedRooms: ImmutableList<SelectRoomInfo>) {
if (isForwarding) return
SelectedRooms(
selectedRooms = selectedRooms,
@ -185,8 +185,8 @@ fun RoomSelectView(
@Composable
private fun SelectedRooms(
selectedRooms: ImmutableList<RoomSummary>,
onRemoveRoom: (RoomSummary) -> Unit,
selectedRooms: ImmutableList<SelectRoomInfo>,
onRemoveRoom: (SelectRoomInfo) -> Unit,
modifier: Modifier = Modifier,
) {
LazyRow(
@ -194,29 +194,29 @@ private fun SelectedRooms(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(32.dp)
) {
items(selectedRooms, key = { it.roomId.value }) { roomSummary ->
SelectedRoom(roomSummary = roomSummary, onRemoveRoom = onRemoveRoom)
items(selectedRooms, key = { it.roomId.value }) { selectRoomInfo ->
SelectedRoom(roomInfo = selectRoomInfo, onRemoveRoom = onRemoveRoom)
}
}
}
@Composable
private fun RoomSummaryView(
summary: RoomSummary,
roomInfo: SelectRoomInfo,
isSelected: Boolean,
onSelection: (RoomSummary) -> Unit,
onSelection: (SelectRoomInfo) -> Unit,
) {
Row(
modifier = Modifier
.clickable { onSelection(summary) }
.clickable { onSelection(roomInfo) }
.fillMaxWidth()
.padding(start = 16.dp, end = 4.dp)
.heightIn(56.dp),
verticalAlignment = Alignment.CenterVertically
) {
CompositeAvatar(
avatarData = summary.getAvatarData(size = AvatarSize.RoomSelectRoomListItem),
heroes = summary.heroes.map { user ->
avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem),
heroes = roomInfo.heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem)
}.toPersistentList()
)
@ -228,14 +228,14 @@ private fun RoomSummaryView(
// Name
Text(
style = ElementTheme.typography.fontBodyLgRegular,
text = summary.name ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { summary.name == null },
text = roomInfo.name ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { roomInfo.name == null },
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Alias
summary.canonicalAlias?.let { alias ->
roomInfo.canonicalAlias?.let { alias ->
Text(
text = alias.value,
color = ElementTheme.colors.textSecondary,
@ -245,7 +245,7 @@ private fun RoomSummaryView(
)
}
}
RadioButton(selected = isSelected, onClick = { onSelection(summary) })
RadioButton(selected = isSelected, onClick = { onSelection(roomInfo) })
}
}

View file

@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.testCoroutineDispatchers
@ -58,8 +59,9 @@ class RoomSelectPresenterTest {
@Test
fun `present - update query`() = runTest {
val roomSummary = aRoomSummary()
val roomListService = FakeRoomListService().apply {
postAllRooms(listOf(aRoomSummary()))
postAllRooms(listOf(roomSummary))
}
val presenter = createRoomSelectPresenter(
roomListService = roomListService
@ -68,19 +70,10 @@ class RoomSelectPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
val expectedRoomSummary = aRoomSummary()
val expectedRoomInfo = roomSummary.toSelectRoomInfo()
// Do not compare the lambda because they will be different. So copy the lambda from expectedRoomSummary to result
val result = (awaitItem().resultState as SearchBarResultState.Results).results.map { roomSummary ->
roomSummary.copy(
lastMessage = roomSummary.lastMessage!!.copy(
event = roomSummary.lastMessage!!.event.copy(
debugInfoProvider = expectedRoomSummary.lastMessage!!.event.debugInfoProvider,
messageShieldProvider = expectedRoomSummary.lastMessage!!.event.messageShieldProvider,
)
),
)
}
assertThat(result).isEqualTo(listOf(expectedRoomSummary))
val result = (awaitItem().resultState as SearchBarResultState.Results).results
assertThat(result).isEqualTo(listOf(expectedRoomInfo))
initialState.eventSink(RoomSelectEvents.ToggleSearchActive)
skipItems(1)
initialState.eventSink(RoomSelectEvents.UpdateQuery("string not contained"))
@ -99,8 +92,9 @@ class RoomSelectPresenterTest {
@Test
fun `present - select and remove a room`() = runTest {
val roomSummary = aRoomSummary()
val roomListService = FakeRoomListService().apply {
postAllRooms(listOf(aRoomSummary()))
postAllRooms(listOf(roomSummary))
}
val presenter = createRoomSelectPresenter(
roomListService = roomListService,
@ -109,9 +103,9 @@ class RoomSelectPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
val summary = aRoomSummary()
initialState.eventSink(RoomSelectEvents.SetSelectedRoom(summary))
assertThat(awaitItem().selectedRooms).isEqualTo(persistentListOf(summary))
val roomInfo = roomSummary.toSelectRoomInfo()
initialState.eventSink(RoomSelectEvents.SetSelectedRoom(roomInfo))
assertThat(awaitItem().selectedRooms).isEqualTo(persistentListOf(roomInfo))
initialState.eventSink(RoomSelectEvents.RemoveSelectedRoom)
assertThat(awaitItem().selectedRooms).isEmpty()
cancel()

View file

@ -8,13 +8,27 @@
package io.element.android.libraries.textcomposer.mentions
import androidx.compose.runtime.Immutable
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
@Immutable
sealed interface ResolvedSuggestion {
data object AtRoom : ResolvedSuggestion
data class Member(val roomMember: RoomMember) : ResolvedSuggestion
data class Alias(val roomAlias: RoomAlias, val roomSummary: RoomSummary) : ResolvedSuggestion
data class Alias(
val roomAlias: RoomAlias,
val roomId: RoomId,
val roomName: String?,
val roomAvatarUrl: String?,
) : ResolvedSuggestion {
fun getAvatarData(size: AvatarSize) = AvatarData(
id = roomId.value,
name = roomName,
url = roomAvatarUrl,
size = size,
)
}
}

View file

@ -16,10 +16,10 @@ import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
@ -34,7 +34,7 @@ class MarkdownTextEditorStateTest {
@Test
fun `insertMention - room alias - getMentions return empty list`() {
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
val suggestion = ResolvedSuggestion.Alias(A_ROOM_ALIAS, aRoomSummary(canonicalAlias = A_ROOM_ALIAS))
val suggestion = aRoomAliasSuggestion()
val permalinkBuilder = FakePermalinkBuilder()
val mentionSpanProvider = aMentionSpanProvider()
state.insertSuggestion(suggestion, mentionSpanProvider, permalinkBuilder)
@ -46,7 +46,7 @@ class MarkdownTextEditorStateTest {
val state = MarkdownTextEditorState(initialText = "Hello #", initialFocus = true).apply {
currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Room, text = "")
}
val suggestion = ResolvedSuggestion.Alias(A_ROOM_ALIAS, aRoomSummary(canonicalAlias = A_ROOM_ALIAS))
val suggestion = aRoomAliasSuggestion()
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
val permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = { Result.failure(IllegalStateException("Failed")) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
@ -58,7 +58,7 @@ class MarkdownTextEditorStateTest {
val state = MarkdownTextEditorState(initialText = "Hello #", initialFocus = true).apply {
currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Room, text = "")
}
val suggestion = ResolvedSuggestion.Alias(A_ROOM_ALIAS, aRoomSummary(canonicalAlias = A_ROOM_ALIAS))
val suggestion = aRoomAliasSuggestion()
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
val permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = { Result.success("https://matrix.to/#/${A_ROOM_ALIAS.value}") })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
@ -202,4 +202,13 @@ class MarkdownTextEditorStateTest {
}
}
}
private fun aRoomAliasSuggestion(): ResolvedSuggestion.Alias {
return ResolvedSuggestion.Alias(
roomAlias = A_ROOM_ALIAS,
roomId = A_ROOM_ID,
roomName = null,
roomAvatarUrl = null
)
}
}

View file

@ -24,4 +24,5 @@ dependencies {
implementation(libs.autonomousapps.dependencyanalysis.plugin)
implementation(libs.anvil.gradle.plugin)
implementation(libs.ksp.gradle.plugin)
implementation(libs.compose.compiler.plugin)
}

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