Merge pull request #1938 from vector-im/feature/fga/user_detail_direct_chat

Feature/fga/user detail direct chat
This commit is contained in:
ganfra
2023-12-01 13:46:01 +01:00
committed by GitHub
45 changed files with 562 additions and 259 deletions

View File

@@ -256,6 +256,10 @@ class LoggedInFlowNode @AssistedInject constructor(
}
is NavTarget.Room -> {
val callback = object : RoomLoadedFlowNode.Callback {
override fun onOpenRoom(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId))
}
override fun onForwardedToSingleRoom(roomId: RoomId) {
coroutineScope.launch { attachRoom(roomId) }
}

View File

@@ -74,6 +74,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
), DaggerComponentOwner {
interface Callback : Plugin {
fun onOpenRoom(roomId: RoomId)
fun onForwardedToSingleRoom(roomId: RoomId)
fun onOpenGlobalNotificationSettings()
}
@@ -134,6 +135,10 @@ class RoomLoadedFlowNode @AssistedInject constructor(
override fun onOpenGlobalNotificationSettings() {
callbacks.forEach { it.onOpenGlobalNotificationSettings() }
}
override fun onOpenRoom(roomId: RoomId) {
callbacks.forEach { it.onOpenRoom(roomId) }
}
}
return roomDetailsEntryPoint.nodeBuilder(this, buildContext)
.params(RoomDetailsEntryPoint.Params(initialTarget))

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.createroom.api
import androidx.compose.runtime.MutableState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
interface StartDMAction {
/**
* Try to find an existing DM with the given user, or create one if none exists.
* @param userId The user to start a DM with.
* @param actionState The state to update with the result of the action.
*/
suspend fun execute(userId: UserId, actionState: MutableState<Async<RoomId>>)
}

View File

@@ -67,6 +67,7 @@ dependencies {
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.features.createroom.test)
testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor)

View File

@@ -0,0 +1,53 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.createroom.impl
import androidx.compose.runtime.MutableState
import com.squareup.anvil.annotations.ContributesBinding
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.createroom.api.StartDMAction
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.di.SessionScope
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.UserId
import io.element.android.libraries.matrix.api.room.StartDMResult
import io.element.android.libraries.matrix.api.room.startDM
import io.element.android.services.analytics.api.AnalyticsService
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultStartDMAction @Inject constructor(
private val matrixClient: MatrixClient,
private val analyticsService: AnalyticsService,
) : StartDMAction {
override suspend fun execute(userId: UserId, actionState: MutableState<Async<RoomId>>) {
actionState.value = Async.Loading()
when (val result = matrixClient.startDM(userId)) {
is StartDMResult.Success -> {
if (result.isNew) {
analyticsService.capture(CreatedRoom(isDM = true))
}
actionState.value = Async.Success(result.roomId)
}
is StartDMResult.Failure -> {
actionState.value = Async.Failure(result.throwable)
}
}
}
}

View File

@@ -21,21 +21,16 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.createroom.api.StartDMAction
import io.element.android.features.createroom.impl.userlist.SelectionMode
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.createroom.impl.userlist.UserListPresenter
import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
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.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserRepository
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -43,8 +38,7 @@ class CreateRoomRootPresenter @Inject constructor(
presenterFactory: UserListPresenter.Factory,
userRepository: UserRepository,
userListDataStore: UserListDataStore,
private val matrixClient: MatrixClient,
private val analyticsService: AnalyticsService,
private val startDMAction: StartDMAction,
private val buildMeta: BuildMeta,
) : Presenter<CreateRoomRootState> {
@@ -61,37 +55,22 @@ class CreateRoomRootPresenter @Inject constructor(
val userListState = presenter.present()
val localCoroutineScope = rememberCoroutineScope()
val startDmAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
val startDmActionState: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
fun handleEvents(event: CreateRoomRootEvents) {
when (event) {
is CreateRoomRootEvents.StartDM -> localCoroutineScope.startDm(event.matrixUser, startDmAction)
CreateRoomRootEvents.CancelStartDM -> startDmAction.value = Async.Uninitialized
is CreateRoomRootEvents.StartDM -> localCoroutineScope.launch {
startDMAction.execute(event.matrixUser.userId, startDmActionState)
}
CreateRoomRootEvents.CancelStartDM -> startDmActionState.value = Async.Uninitialized
}
}
return CreateRoomRootState(
applicationName = buildMeta.applicationName,
userListState = userListState,
startDmAction = startDmAction.value,
startDmAction = startDmActionState.value,
eventSink = ::handleEvents,
)
}
private fun CoroutineScope.startDm(matrixUser: MatrixUser, startDmAction: MutableState<Async<RoomId>>) = launch {
suspend {
matrixClient.findDM(matrixUser.userId).use { existingDM ->
existingDM?.roomId ?: createDM(matrixUser)
}
}.runCatchingUpdatingState(startDmAction)
}
private suspend fun createDM(user: MatrixUser): RoomId {
return matrixClient
.createDM(user.userId)
.onSuccess {
analyticsService.capture(CreatedRoom(isDM = true))
}
.getOrThrow()
}
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.createroom.impl
import androidx.compose.runtime.mutableStateOf
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
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.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultStartDMActionTests {
@Test
fun `when dm is found, assert state is updated with given room id`() = runTest {
val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(A_ROOM_ID)
}
val action = createStartDMAction(matrixClient)
val state = mutableStateOf<Async<RoomId>>(Async.Uninitialized)
action.execute(A_USER_ID, state)
assertThat(state.value).isEqualTo(Async.Success(A_ROOM_ID))
}
@Test
fun `when dm is not found, assert dm is created, state is updated with given room id and analytics get called`() = runTest {
val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(null)
givenCreateDmResult(Result.success(A_ROOM_ID))
}
val analyticsService = FakeAnalyticsService()
val action = createStartDMAction(matrixClient, analyticsService)
val state = mutableStateOf<Async<RoomId>>(Async.Uninitialized)
action.execute(A_USER_ID, state)
assertThat(state.value).isEqualTo(Async.Success(A_ROOM_ID))
assertThat(analyticsService.capturedEvents).containsExactly(CreatedRoom(isDM = true))
}
@Test
fun `when dm creation fails, assert state is updated with given error`() = runTest {
val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(null)
givenCreateDmResult(Result.failure(A_THROWABLE))
}
val action = createStartDMAction(matrixClient)
val state = mutableStateOf<Async<RoomId>>(Async.Uninitialized)
action.execute(A_USER_ID, state)
assertThat(state.value).isEqualTo(Async.Failure<RoomId>(A_THROWABLE))
}
private fun createStartDMAction(
matrixClient: MatrixClient = FakeMatrixClient(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
): DefaultStartDMAction {
return DefaultStartDMAction(
matrixClient = matrixClient,
analyticsService = analyticsService,
)
}
}

View File

@@ -20,25 +20,21 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.createroom.api.StartDMAction
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.features.createroom.test.FakeStartDMAction
import io.element.android.libraries.architecture.Async
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.user.MatrixUser
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.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -47,142 +43,57 @@ class CreateRoomRootPresenterTests {
@get:Rule
val warmUpRule = WarmUpRule()
private lateinit var userRepository: FakeUserRepository
private lateinit var presenter: CreateRoomRootPresenter
private lateinit var fakeUserListPresenter: FakeUserListPresenter
private lateinit var fakeMatrixClient: FakeMatrixClient
private lateinit var fakeAnalyticsService: FakeAnalyticsService
@Before
fun setup() {
fakeUserListPresenter = FakeUserListPresenter()
fakeMatrixClient = FakeMatrixClient()
fakeAnalyticsService = FakeAnalyticsService()
userRepository = FakeUserRepository()
presenter = CreateRoomRootPresenter(
presenterFactory = FakeUserListPresenterFactory(fakeUserListPresenter),
userRepository = userRepository,
userListDataStore = UserListDataStore(),
matrixClient = fakeMatrixClient,
analyticsService = fakeAnalyticsService,
buildMeta = aBuildMeta(),
)
}
@Test
fun `present - initial state`() = runTest {
fun `present - start DM action complete scenario`() = runTest {
val startDMAction = FakeStartDMAction()
val presenter = createCreateRoomRootPresenter(startDMAction)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.startDmAction).isInstanceOf(Async.Uninitialized::class.java)
assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
assertThat(initialState.userListState.selectedUsers).isEmpty()
assertThat(initialState.userListState.isSearchActive).isFalse()
assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse()
}
}
@Test
fun `present - trigger create DM action`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:domain"))
val createDmResult = Result.success(RoomId("!createDmResult:domain"))
fakeMatrixClient.givenFindDmResult(null)
fakeMatrixClient.givenCreateDmResult(createDmResult)
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
val stateAfterStartDM = awaitItem()
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(createDmResult.getOrNull())
}
}
@Test
fun `present - creating a DM records analytics event`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:domain"))
val createDmResult = Result.success(RoomId("!createDmResult:domain"))
fakeMatrixClient.givenFindDmResult(null)
fakeMatrixClient.givenCreateDmResult(createDmResult)
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
skipItems(2)
val analyticsEvent = fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>().firstOrNull()
assertThat(analyticsEvent).isNotNull()
assertThat(analyticsEvent?.isDM).isTrue()
}
}
@Test
fun `present - trigger retrieve DM action`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:domain"))
val fakeDmResult = FakeMatrixRoom(roomId = RoomId("!fakeDmResult:domain"))
fakeMatrixClient.givenFindDmResult(fakeDmResult)
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
val stateAfterStartDM = awaitItem()
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(fakeDmResult.roomId)
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
}
}
@Test
fun `present - trigger retry create DM action`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:domain"))
val createDmResult = Result.success(RoomId("!createDmResult:domain"))
fakeUserListPresenter.givenState(aUserListState().copy(selectedUsers = persistentListOf(matrixUser)))
fakeMatrixClient.givenFindDmResult(null)
fakeMatrixClient.givenCreateDmError(A_THROWABLE)
fakeMatrixClient.givenCreateDmResult(createDmResult)
val startDMSuccessResult = Async.Success(A_ROOM_ID)
val startDMFailureResult = Async.Failure<RoomId>(A_THROWABLE)
// Failure
startDMAction.givenExecuteResult(startDMFailureResult)
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
val stateAfterStartDM = awaitItem()
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Failure::class.java)
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
awaitItem().also { state ->
assertThat(state.startDmAction).isEqualTo(startDMFailureResult)
state.eventSink(CreateRoomRootEvents.CancelStartDM)
}
// Cancel
stateAfterStartDM.eventSink(CreateRoomRootEvents.CancelStartDM)
val stateAfterCancel = awaitItem()
assertThat(stateAfterCancel.startDmAction).isInstanceOf(Async.Uninitialized::class.java)
// Failure
stateAfterCancel.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
// Success
startDMAction.givenExecuteResult(startDMSuccessResult)
awaitItem().also { state ->
assertThat(state.startDmAction).isEqualTo(Async.Uninitialized)
state.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
}
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
val stateAfterSecondAttempt = awaitItem()
assertThat(stateAfterSecondAttempt.startDmAction).isInstanceOf(Async.Failure::class.java)
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
awaitItem().also { state ->
assertThat(state.startDmAction).isEqualTo(startDMSuccessResult)
}
// Retry with success
fakeMatrixClient.givenCreateDmError(null)
stateAfterSecondAttempt.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
val stateAfterRetryStartDM = awaitItem()
assertThat(stateAfterRetryStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
assertThat(stateAfterRetryStartDM.startDmAction.dataOrNull()).isEqualTo(createDmResult.getOrNull())
}
}
private fun createCreateRoomRootPresenter(
startDMAction: StartDMAction = FakeStartDMAction(),
): CreateRoomRootPresenter {
return CreateRoomRootPresenter(
presenterFactory = FakeUserListPresenterFactory(FakeUserListPresenter()),
userRepository = FakeUserRepository(),
userListDataStore = UserListDataStore(),
startDMAction = startDMAction,
buildMeta = aBuildMeta(),
)
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.createroom.test"
}
dependencies {
implementation(libs.coroutines.core)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.test)
implementation(projects.libraries.architecture)
api(projects.features.createroom.api)
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.createroom.test
import androidx.compose.runtime.MutableState
import io.element.android.features.createroom.api.StartDMAction
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import kotlinx.coroutines.delay
class FakeStartDMAction : StartDMAction {
private var executeResult: Async<RoomId> = Async.Success(A_ROOM_ID)
fun givenExecuteResult(result: Async<RoomId>) {
executeResult = result
}
override suspend fun execute(userId: UserId, actionState: MutableState<Async<RoomId>>) {
actionState.value = Async.Loading()
delay(1)
actionState.value = executeResult
}
}

View File

@@ -22,6 +22,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.parcelize.Parcelize
@@ -42,6 +43,7 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onOpenGlobalNotificationSettings()
fun onOpenRoom(roomId: RoomId)
}
interface NodeBuilder {

View File

@@ -51,6 +51,7 @@ dependencies {
api(projects.services.apperror.api)
implementation(libs.coil.compose)
implementation(projects.features.leaveroom.api)
implementation(projects.features.createroom.api)
implementation(projects.services.analytics.api)
testImplementation(libs.test.junit)
@@ -67,6 +68,7 @@ dependencies {
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.features.leaveroom.test)
testImplementation(projects.features.createroom.test)
ksp(libs.showkase.processor)
}

View File

@@ -41,6 +41,7 @@ import io.element.android.libraries.architecture.animation.rememberDefaultTransi
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.RoomScope
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.media.MediaSource
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
@@ -152,6 +153,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun openAvatarPreview(username: String, avatarUrl: String) {
backstack.push(NavTarget.MemberAvatarPreview(username, avatarUrl))
}
override fun onStartDM(roomId: RoomId) {
plugins<RoomDetailsEntryPoint.Callback>().forEach { it.onOpenRoom(roomId) }
}
}
val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId), callback)
createNode<RoomMemberDetailsNode>(buildContext, plugins)

View File

@@ -85,6 +85,7 @@ private fun PreferenceBlockUser(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block)),
onClick = { if (!isLoading) eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = true)) },
trailingContent = if (isLoading) ListItemContent.Custom(loadingCurrentValue) else null,
style = ListItemStyle.Primary,
modifier = modifier,
)
} else {

View File

@@ -19,6 +19,7 @@ package io.element.android.features.roomdetails.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.features.createroom.api.StartDMAction
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient
@@ -33,10 +34,11 @@ object RoomMemberModule {
fun provideRoomMemberDetailsPresenterFactory(
matrixClient: MatrixClient,
room: MatrixRoom,
startDMAction: StartDMAction,
): RoomMemberDetailsPresenter.Factory {
return object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter(matrixClient, room, roomMemberId)
return RoomMemberDetailsPresenter(roomMemberId, matrixClient, room, startDMAction)
}
}
}

View File

@@ -17,6 +17,8 @@
package io.element.android.features.roomdetails.impl.members.details
sealed interface RoomMemberDetailsEvents {
data object StartDM : RoomMemberDetailsEvents
data object ClearStartDMState : RoomMemberDetailsEvents
data class BlockUser(val needsConfirmation: Boolean = false) : RoomMemberDetailsEvents
data class UnblockUser(val needsConfirmation: Boolean = false) : RoomMemberDetailsEvents
data object ClearBlockUserError : RoomMemberDetailsEvents

View File

@@ -17,6 +17,7 @@
package io.element.android.features.roomdetails.impl.members.details
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.lifecycle.subscribe
@@ -29,9 +30,11 @@ import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
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.permalink.PermalinkBuilder
import io.element.android.services.analytics.api.AnalyticsService
@@ -46,8 +49,9 @@ class RoomMemberDetailsNode @AssistedInject constructor(
presenterFactory: RoomMemberDetailsPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
interface Callback: NodeInputs {
interface Callback : NodeInputs {
fun openAvatarPreview(username: String, avatarUrl: String)
fun onStartDM(roomId: RoomId)
}
data class RoomMemberDetailsInput(
@@ -84,12 +88,23 @@ class RoomMemberDetailsNode @AssistedInject constructor(
}
}
fun onStartDM(roomId: RoomId) {
callback.onStartDM(roomId)
}
val state = presenter.present()
LaunchedEffect(state.startDmActionState) {
if (state.startDmActionState is Async.Success) {
onStartDM(state.startDmActionState.data)
}
}
RoomMemberDetailsView(
state = state,
modifier = modifier,
goBack = this::navigateUp,
onShareUser = ::onShareUser,
onDMStarted = ::onStartDM,
openAvatarPreview = callback::openAvatarPreview,
)
}

View File

@@ -27,11 +27,13 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.features.createroom.api.StartDMAction
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState.ConfirmationDialog
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
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.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
@@ -39,9 +41,10 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class RoomMemberDetailsPresenter @AssistedInject constructor(
@Assisted private val roomMemberId: UserId,
private val client: MatrixClient,
private val room: MatrixRoom,
@Assisted private val roomMemberId: UserId,
private val startDMAction: StartDMAction,
) : Presenter<RoomMemberDetailsState> {
interface Factory {
@@ -53,6 +56,7 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
val coroutineScope = rememberCoroutineScope()
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
val roomMember by room.getRoomMemberAsState(roomMemberId)
val startDmActionState: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
// the room member is not really live...
val isBlocked: MutableState<Async<Boolean>> = remember(roomMember) {
val isIgnored = roomMember?.isIgnored
@@ -88,6 +92,14 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
RoomMemberDetailsEvents.ClearBlockUserError -> {
isBlocked.value = Async.Success(isBlocked.value.dataOrNull().orFalse())
}
RoomMemberDetailsEvents.StartDM -> {
coroutineScope.launch {
startDMAction.execute(roomMemberId, startDmActionState)
}
}
RoomMemberDetailsEvents.ClearStartDMState -> {
startDmActionState.value = Async.Uninitialized
}
}
}
@@ -108,6 +120,7 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
userName = userName,
avatarUrl = userAvatar,
isBlocked = isBlocked.value,
startDmActionState = startDmActionState.value,
displayConfirmationDialog = confirmationDialog,
isCurrentUser = client.isMe(roomMember?.userId),
eventSink = ::handleEvents

View File

@@ -17,12 +17,14 @@
package io.element.android.features.roomdetails.impl.members.details
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
data class RoomMemberDetailsState(
val userId: String,
val userName: String?,
val avatarUrl: String?,
val isBlocked: Async<Boolean>,
val startDmActionState: Async<RoomId>,
val displayConfirmationDialog: ConfirmationDialog?,
val isCurrentUser: Boolean,
val eventSink: (RoomMemberDetailsEvents) -> Unit

View File

@@ -28,6 +28,7 @@ open class RoomMemberDetailsStateProvider : PreviewParameterProvider<RoomMemberD
aRoomMemberDetailsState().copy(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block),
aRoomMemberDetailsState().copy(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock),
aRoomMemberDetailsState().copy(isBlocked = Async.Loading(true)),
aRoomMemberDetailsState().copy(startDmActionState = Async.Loading()),
// Add other states here
)
}
@@ -37,6 +38,7 @@ fun aRoomMemberDetailsState() = RoomMemberDetailsState(
userName = "Daniel",
avatarUrl = null,
isBlocked = Async.Success(false),
startDmActionState = Async.Uninitialized,
displayConfirmationDialog = null,
isCurrentUser = false,
eventSink = {},

View File

@@ -26,22 +26,34 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomdetails.impl.R
import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
import io.element.android.libraries.designsystem.components.async.AsyncView
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomMemberDetailsView(
state: RoomMemberDetailsState,
onShareUser: () -> Unit,
onDMStarted: (RoomId) -> Unit,
goBack: () -> Unit,
openAvatarPreview: (username: String, url: String) -> Unit,
modifier: Modifier = Modifier,
@@ -71,31 +83,36 @@ fun RoomMemberDetailsView(
Spacer(modifier = Modifier.height(26.dp))
// TODO implement send DM
// SendMessageSection(onSendMessage = {
// ...
// })
if (!state.isCurrentUser) {
StartDMSection(onStartDMClicked = { state.eventSink(RoomMemberDetailsEvents.StartDM) })
BlockUserSection(state)
BlockUserDialogs(state)
}
AsyncView(
async = state.startDmActionState,
progressText = stringResource(CommonStrings.common_starting_chat),
onSuccess = onDMStarted,
errorMessage = { stringResource(R.string.screen_start_chat_error_starting_chat) },
onRetry = { state.eventSink(RoomMemberDetailsEvents.StartDM) },
onErrorDismiss = { state.eventSink(RoomMemberDetailsEvents.ClearStartDMState) },
)
}
}
}
/*
@Composable
private fun SendMessageSection(onSendMessage: () -> Unit, modifier: Modifier = Modifier) {
PreferenceCategory(modifier = modifier) {
PreferenceText(
title = stringResource(CommonStrings.action_send_message),
icon = Icons.Outlined.ChatBubbleOutline,
onClick = onSendMessage,
)
}
private fun StartDMSection(
onStartDMClicked: () -> Unit,
modifier: Modifier = Modifier
) {
ListItem(
headlineContent = { Text(stringResource(CommonStrings.common_direct_chat)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Chat)),
style = ListItemStyle.Primary,
onClick = onStartDMClicked,
modifier = modifier,
)
}
*/
@PreviewWithLargeHeight
@Composable
@@ -113,6 +130,7 @@ private fun ContentToPreview(state: RoomMemberDetailsState) {
state = state,
onShareUser = {},
goBack = {},
onDMStarted = {},
openAvatarPreview = { _, _ -> }
)
}

View File

@@ -38,6 +38,7 @@
<string name="screen_room_notification_settings_mentions_only_disclaimer">"Your homeserver does not support this option in encrypted rooms, you won\'t get notified in this room."</string>
<string name="screen_room_notification_settings_mode_all_messages">"All messages"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"In this room, notify me for"</string>
<string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string>
<string name="screen_dm_details_block_alert_action">"Block"</string>
<string name="screen_dm_details_block_alert_description">"Blocked users won\'t be able to send you messages and all their messages will be hidden. You can unblock them anytime."</string>
<string name="screen_dm_details_block_user">"Block user"</string>

View File

@@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.test.FakeStartDMAction
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter
@@ -45,11 +46,12 @@ import io.element.android.libraries.matrix.test.A_USER_ID_2
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.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -61,16 +63,16 @@ class RoomDetailsPresenterTests {
@get:Rule
val warmUpRule = WarmUpRule()
private fun createRoomDetailsPresenter(
private fun TestScope.createRoomDetailsPresenter(
room: MatrixRoom,
leaveRoomPresenter: LeaveRoomPresenter = FakeLeaveRoomPresenter(),
dispatchers: CoroutineDispatchers,
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService()
): RoomDetailsPresenter {
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter(matrixClient, room, roomMemberId)
return RoomMemberDetailsPresenter(roomMemberId, matrixClient, room, FakeStartDMAction())
}
}
val featureFlagService = FakeFeatureFlagService(
@@ -90,7 +92,7 @@ class RoomDetailsPresenterTests {
@Test
fun `present - initial state is created from room info`() = runTest {
val room = aMatrixRoom()
val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -109,7 +111,7 @@ class RoomDetailsPresenterTests {
@Test
fun `present - initial state with no room name`() = runTest {
val room = aMatrixRoom(name = null)
val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -131,7 +133,7 @@ class RoomDetailsPresenterTests {
val roomMembers = persistentListOf(myRoomMember, otherRoomMember)
givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
}
val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -165,7 +167,7 @@ class RoomDetailsPresenterTests {
val room = aMatrixRoom().apply {
givenCanInviteResult(Result.success(false))
}
val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -180,7 +182,7 @@ class RoomDetailsPresenterTests {
val room = aMatrixRoom().apply {
givenCanInviteResult(Result.failure(Throwable("Whoops")))
}
val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -198,7 +200,7 @@ class RoomDetailsPresenterTests {
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.failure(Throwable("Whelp")))
givenCanInviteResult(Result.success(false))
}
val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -227,7 +229,7 @@ class RoomDetailsPresenterTests {
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(true))
givenCanInviteResult(Result.success(false))
}
val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -242,6 +244,7 @@ class RoomDetailsPresenterTests {
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - initial state when in a DM with no topic`() = runTest {
val myRoomMember = aRoomMember(A_SESSION_ID)
@@ -256,7 +259,7 @@ class RoomDetailsPresenterTests {
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
}
val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -277,7 +280,7 @@ class RoomDetailsPresenterTests {
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(true))
givenCanInviteResult(Result.success(false))
}
val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -298,7 +301,7 @@ class RoomDetailsPresenterTests {
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(false))
givenCanInviteResult(Result.success(false))
}
val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -316,7 +319,7 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.success(false))
}
val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -334,7 +337,7 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.success(false))
}
val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -352,7 +355,11 @@ class RoomDetailsPresenterTests {
fun `present - leave room event is passed on to leave room presenter`() = runTest {
val leaveRoomPresenter = FakeLeaveRoomPresenter()
val room = aMatrixRoom()
val presenter = createRoomDetailsPresenter(room, leaveRoomPresenter, testCoroutineDispatchers())
val presenter = createRoomDetailsPresenter(
room = room,
leaveRoomPresenter = leaveRoomPresenter,
dispatchers = testCoroutineDispatchers()
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -369,14 +376,18 @@ class RoomDetailsPresenterTests {
val leaveRoomPresenter = FakeLeaveRoomPresenter()
val notificationSettingsService = FakeNotificationSettingsService()
val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
val presenter = createRoomDetailsPresenter(room, leaveRoomPresenter, testCoroutineDispatchers(), notificationSettingsService)
val presenter = createRoomDetailsPresenter(
room = room,
leaveRoomPresenter = leaveRoomPresenter,
notificationSettingsService = notificationSettingsService,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
notificationSettingsService.setRoomNotificationMode(room.roomId, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
val updatedState = consumeItemsUntilPredicate {
it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
}.last()
assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
cancelAndIgnoreRemainingEvents()
@@ -385,16 +396,15 @@ class RoomDetailsPresenterTests {
@Test
fun `present - mute room notifications`() = runTest {
val leaveRoomPresenter = FakeLeaveRoomPresenter()
val notificationSettingsService = FakeNotificationSettingsService(initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
val presenter = createRoomDetailsPresenter(room, leaveRoomPresenter, testCoroutineDispatchers(), notificationSettingsService)
val presenter = createRoomDetailsPresenter(room = room, notificationSettingsService = notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(RoomDetailsEvent.MuteNotification)
val updatedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) {
it.roomNotificationSettings?.mode == RoomNotificationMode.MUTE
it.roomNotificationSettings?.mode == RoomNotificationMode.MUTE
}.last()
assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MUTE)
cancelAndIgnoreRemainingEvents()
@@ -403,19 +413,18 @@ class RoomDetailsPresenterTests {
@Test
fun `present - unmute room notifications`() = runTest {
val leaveRoomPresenter = FakeLeaveRoomPresenter()
val notificationSettingsService = FakeNotificationSettingsService(
initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
initialEncryptedGroupDefaultMode = RoomNotificationMode.ALL_MESSAGES
)
val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
val presenter = createRoomDetailsPresenter(room, leaveRoomPresenter, testCoroutineDispatchers(), notificationSettingsService)
val presenter = createRoomDetailsPresenter(room = room, notificationSettingsService = notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(RoomDetailsEvent.UnmuteNotification)
val updatedState = consumeItemsUntilPredicate {
it.roomNotificationSettings?.mode == RoomNotificationMode.ALL_MESSAGES
it.roomNotificationSettings?.mode == RoomNotificationMode.ALL_MESSAGES
}.last()
assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.ALL_MESSAGES)
cancelAndIgnoreRemainingEvents()

View File

@@ -20,13 +20,20 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.features.createroom.api.StartDMAction
import io.element.android.features.createroom.test.FakeStartDMAction
import io.element.android.features.roomdetails.aMatrixRoom
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
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.tests.testutils.WarmUpRule
@@ -50,7 +57,10 @@ class RoomMemberDetailsPresenterTests {
givenUserAvatarUrlResult(Result.success("A custom avatar"))
givenRoomMembersState(MatrixRoomMembersState.Ready(persistentListOf(roomMember)))
}
val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId)
val presenter = createRoomMemberDetailsPresenter(
room = room,
roomMember = roomMember
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -74,7 +84,10 @@ class RoomMemberDetailsPresenterTests {
givenUserAvatarUrlResult(Result.failure(Throwable()))
givenRoomMembersState(MatrixRoomMembersState.Ready(persistentListOf(roomMember)))
}
val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId)
val presenter = createRoomMemberDetailsPresenter(
room = room,
roomMember = roomMember
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -94,7 +107,10 @@ class RoomMemberDetailsPresenterTests {
givenUserAvatarUrlResult(Result.success(null))
givenRoomMembersState(MatrixRoomMembersState.Ready(persistentListOf(roomMember)))
}
val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId)
val presenter = createRoomMemberDetailsPresenter(
room = room,
roomMember = roomMember
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -108,9 +124,7 @@ class RoomMemberDetailsPresenterTests {
@Test
fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest {
val room = aMatrixRoom()
val roomMember = aRoomMember()
val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId)
val presenter = createRoomMemberDetailsPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -129,9 +143,7 @@ class RoomMemberDetailsPresenterTests {
@Test
fun `present - BlockUser and UnblockUser without confirmation change the 'blocked' state`() = runTest {
val room = aMatrixRoom()
val roomMember = aRoomMember()
val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId)
val presenter = createRoomMemberDetailsPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -148,11 +160,9 @@ class RoomMemberDetailsPresenterTests {
@Test
fun `present - BlockUser with error`() = runTest {
val room = aMatrixRoom()
val roomMember = aRoomMember()
val matrixClient = FakeMatrixClient()
matrixClient.givenIgnoreUserResult(Result.failure(A_THROWABLE))
val presenter = RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
val presenter = createRoomMemberDetailsPresenter(client = matrixClient)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -169,9 +179,7 @@ class RoomMemberDetailsPresenterTests {
@Test
fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest {
val room = aMatrixRoom()
val roomMember = aRoomMember()
val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId)
val presenter = createRoomMemberDetailsPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -187,4 +195,52 @@ class RoomMemberDetailsPresenterTests {
ensureAllEventsConsumed()
}
}
@Test
fun `present - start DM action complete scenario`() = runTest {
val startDMAction = FakeStartDMAction()
val presenter = createRoomMemberDetailsPresenter(startDMAction = startDMAction)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.startDmActionState).isInstanceOf(Async.Uninitialized::class.java)
val startDMSuccessResult = Async.Success(A_ROOM_ID)
val startDMFailureResult = Async.Failure<RoomId>(A_THROWABLE)
// Failure
startDMAction.givenExecuteResult(startDMFailureResult)
initialState.eventSink(RoomMemberDetailsEvents.StartDM)
Truth.assertThat(awaitItem().startDmActionState).isInstanceOf(Async.Loading::class.java)
awaitItem().also { state ->
Truth.assertThat(state.startDmActionState).isEqualTo(startDMFailureResult)
state.eventSink(RoomMemberDetailsEvents.ClearStartDMState)
}
// Success
startDMAction.givenExecuteResult(startDMSuccessResult)
awaitItem().also { state ->
Truth.assertThat(state.startDmActionState).isEqualTo(Async.Uninitialized)
state.eventSink(RoomMemberDetailsEvents.StartDM)
}
Truth.assertThat(awaitItem().startDmActionState).isInstanceOf(Async.Loading::class.java)
awaitItem().also { state ->
Truth.assertThat(state.startDmActionState).isEqualTo(startDMSuccessResult)
}
}
}
private fun createRoomMemberDetailsPresenter(
client: MatrixClient = FakeMatrixClient(),
room: MatrixRoom = aMatrixRoom(),
roomMember: RoomMember = aRoomMember(),
startDMAction: StartDMAction = FakeStartDMAction()
): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter(
roomMemberId = roomMember.userId,
client = client,
room = room,
startDMAction = startDMAction
)
}
}

View File

@@ -41,7 +41,7 @@ interface MatrixClient : Closeable {
val roomListService: RoomListService
val mediaLoader: MatrixMediaLoader
suspend fun getRoom(roomId: RoomId): MatrixRoom?
suspend fun findDM(userId: UserId): MatrixRoom?
suspend fun findDM(userId: UserId): RoomId?
suspend fun ignoreUser(userId: UserId): Result<Unit>
suspend fun unignoreUser(userId: UserId): Result<Unit>
suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId>

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.room
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.UserId
/**
* Try to find an existing DM with the given user, or create one if none exists.
*/
suspend fun MatrixClient.startDM(userId: UserId): StartDMResult {
val existingDM = findDM(userId)
return if (existingDM != null) {
StartDMResult.Success(existingDM, isNew = false)
} else {
createDM(userId).fold(
{ StartDMResult.Success(it, isNew = true) },
{ StartDMResult.Failure(it) }
)
}
}
sealed interface StartDMResult {
data class Success(val roomId: RoomId, val isNew: Boolean) : StartDMResult
data class Failure(val throwable: Throwable) : StartDMResult
}

View File

@@ -241,9 +241,8 @@ class RustMatrixClient constructor(
}
}
override suspend fun findDM(userId: UserId): MatrixRoom? {
val roomId = client.getDmRoom(userId.value)?.use { RoomId(it.id()) }
return roomId?.let { getRoom(it) }
override suspend fun findDM(userId: UserId): RoomId? {
return client.getDmRoom(userId.value)?.use { RoomId(it.id()) }
}
override suspend fun ignoreUser(userId: UserId): Result<Unit> = withContext(sessionDispatcher) {

View File

@@ -39,7 +39,6 @@ import io.element.android.libraries.matrix.test.media.FakeMediaLoader
import io.element.android.libraries.matrix.test.notification.FakeNotificationService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.pushers.FakePushersService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
@@ -72,8 +71,7 @@ class FakeMatrixClient(
private var unignoreUserResult: Result<Unit> = Result.success(Unit)
private var createRoomResult: Result<RoomId> = Result.success(A_ROOM_ID)
private var createDmResult: Result<RoomId> = Result.success(A_ROOM_ID)
private var createDmFailure: Throwable? = null
private var findDmResult: MatrixRoom? = FakeMatrixRoom()
private var findDmResult: RoomId? = A_ROOM_ID
private var logoutFailure: Throwable? = null
private val getRoomResults = mutableMapOf<RoomId, MatrixRoom>()
private val searchUserResults = mutableMapOf<String, Result<MatrixSearchUserResults>>()
@@ -87,7 +85,7 @@ class FakeMatrixClient(
return getRoomResults[roomId]
}
override suspend fun findDM(userId: UserId): MatrixRoom? {
override suspend fun findDM(userId: UserId): RoomId? {
return findDmResult
}
@@ -99,14 +97,11 @@ class FakeMatrixClient(
return unignoreUserResult
}
override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId> {
delay(100)
override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId> = simulateLongTask {
return createRoomResult
}
override suspend fun createDM(userId: UserId): Result<RoomId> {
delay(100)
createDmFailure?.let { throw it }
override suspend fun createDM(userId: UserId): Result<RoomId> = simulateLongTask {
return createDmResult
}
@@ -206,11 +201,7 @@ class FakeMatrixClient(
unignoreUserResult = result
}
fun givenCreateDmError(failure: Throwable?) {
createDmFailure = failure
}
fun givenFindDmResult(result: MatrixRoom?) {
fun givenFindDmResult(result: RoomId?) {
findDmResult = result
}

View File

@@ -109,6 +109,7 @@
<string name="common_dark">"Dark"</string>
<string name="common_decryption_error">"Decryption error"</string>
<string name="common_developer_options">"Developer options"</string>
<string name="common_direct_chat">"Direct chat"</string>
<string name="common_edited_suffix">"(edited)"</string>
<string name="common_editing">"Editing"</string>
<string name="common_emote">"* %1$s %2$s"</string>
@@ -227,9 +228,6 @@
<item quantity="other">"%d votes"</item>
</plurals>
<string name="preference_rageshake">"Rageshake to report bug"</string>
<string name="screen_edit_poll_delete_confirmation">"Are you sure you want to delete this poll?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Delete Poll"</string>
<string name="screen_edit_poll_title">"Edit poll"</string>
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>

View File

@@ -82,9 +82,9 @@ includeProjects(File(rootDir, "services"), ":services")
// Uncomment to include the compound-android module as a local dependency so you can work on it locally.
// You will also need to clone it in the specified folder.
//includeBuild("checkouts/compound-android") {
// includeBuild("checkouts/compound-android") {
// dependencySubstitution {
// // substitute remote dependency with local module
// substitute(module("io.element.android:compound-android")).using(project(":compound"))
// }
//}
// }

View File

@@ -115,7 +115,8 @@
"screen_room_member_list_.*",
"screen_dm_details_.*",
"screen_room_notification_settings_.*",
"screen_notification_settings_edit_failed_updating_default_mode"
"screen_notification_settings_edit_failed_updating_default_mode",
"screen_start_chat_error_starting_chat"
]
},
{