Merge pull request #3700 from element-hq/feature/bma/removeDuplicatedCode

Remove duplicated code regarding user (room member and user profile) screens
This commit is contained in:
Benoit Marty 2024-10-18 09:13:36 +02:00 committed by GitHub
commit fa1a1975f2
20 changed files with 283 additions and 504 deletions

View file

@ -9,7 +9,7 @@ package io.element.android.features.roomdetails.impl
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.userprofile.shared.UserProfileState import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.libraries.matrix.api.core.RoomAlias 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.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember

View file

@ -11,7 +11,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.features.roomdetails.impl.members.aRoomMember import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.userprofile.shared.UserProfileState import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.shared.aUserProfileState import io.element.android.features.userprofile.shared.aUserProfileState
import io.element.android.libraries.matrix.api.core.RoomAlias 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.RoomId

View file

@ -10,10 +10,9 @@ package io.element.android.features.roomdetails.impl.di
import com.squareup.anvil.annotations.ContributesTo import com.squareup.anvil.annotations.ContributesTo
import dagger.Module import dagger.Module
import dagger.Provides 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.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -22,13 +21,16 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
object RoomMemberModule { object RoomMemberModule {
@Provides @Provides
fun provideRoomMemberDetailsPresenterFactory( fun provideRoomMemberDetailsPresenterFactory(
matrixClient: MatrixClient,
room: MatrixRoom, room: MatrixRoom,
startDMAction: StartDMAction, userProfilePresenterFactory: UserProfilePresenterFactory,
): RoomMemberDetailsPresenter.Factory { ): RoomMemberDetailsPresenter.Factory {
return object : RoomMemberDetailsPresenter.Factory { return object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter { override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter(roomMemberId, matrixClient, room, startDMAction) return RoomMemberDetailsPresenter(
roomMemberId = roomMemberId,
room = room,
userProfilePresenterFactory = userProfilePresenterFactory,
)
} }
} }
} }

View file

@ -9,153 +9,60 @@ package io.element.android.features.roomdetails.impl.members.details
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.createroom.api.StartDMAction import io.element.android.features.userprofile.api.UserProfilePresenterFactory
import io.element.android.features.userprofile.shared.UserProfileEvents import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.shared.UserProfilePresenterHelper
import io.element.android.features.userprofile.shared.UserProfileState
import io.element.android.features.userprofile.shared.UserProfileState.ConfirmationDialog
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter 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.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
/**
* Presenter for room member details screen.
* Rely on UserProfilePresenter, but override some fields with room member info when available.
*/
class RoomMemberDetailsPresenter @AssistedInject constructor( class RoomMemberDetailsPresenter @AssistedInject constructor(
@Assisted private val roomMemberId: UserId, @Assisted private val roomMemberId: UserId,
private val client: MatrixClient,
private val room: MatrixRoom, private val room: MatrixRoom,
private val startDMAction: StartDMAction, userProfilePresenterFactory: UserProfilePresenterFactory,
) : Presenter<UserProfileState> { ) : Presenter<UserProfileState> {
interface Factory { interface Factory {
fun create(roomMemberId: UserId): RoomMemberDetailsPresenter fun create(roomMemberId: UserId): RoomMemberDetailsPresenter
} }
private val userProfilePresenterHelper = UserProfilePresenterHelper( private val userProfilePresenter = userProfilePresenterFactory.create(roomMemberId)
userId = roomMemberId,
client = client,
)
@Composable @Composable
override fun present(): UserProfileState { override fun present(): UserProfileState {
val coroutineScope = rememberCoroutineScope()
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
val roomMember by room.getRoomMemberAsState(roomMemberId) val roomMember by room.getRoomMemberAsState(roomMemberId)
var userProfile by remember { mutableStateOf<MatrixUser?>(null) }
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val isBlocked: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
val isCurrentUser = remember { client.isMe(roomMemberId) }
val dmRoomId by userProfilePresenterHelper.getDmRoomId()
val canCall by userProfilePresenterHelper.getCanCall(dmRoomId)
LaunchedEffect(Unit) {
client.ignoredUsersFlow
.map { ignoredUsers -> roomMemberId in ignoredUsers }
.distinctUntilChanged()
.onEach { isBlocked.value = AsyncData.Success(it) }
.launchIn(this)
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Update room member info when opening this screen // Update room member info when opening this screen
// We don't need to assign the result as it will be automatically propagated by `room.getRoomMemberAsState` // We don't need to assign the result as it will be automatically propagated by `room.getRoomMemberAsState`
room.getUpdatedMember(roomMemberId) room.getUpdatedMember(roomMemberId)
.onFailure {
// Not a member of the room, try to get the user profile
userProfile = client.getProfile(roomMemberId).getOrNull()
}
} }
fun handleEvents(event: UserProfileEvents) { val roomUserName: String? by produceState(
when (event) { initialValue = roomMember?.displayName,
is UserProfileEvents.BlockUser -> {
if (event.needsConfirmation) {
confirmationDialog = ConfirmationDialog.Block
} else {
confirmationDialog = null
userProfilePresenterHelper.blockUser(coroutineScope, isBlocked)
}
}
is UserProfileEvents.UnblockUser -> {
if (event.needsConfirmation) {
confirmationDialog = ConfirmationDialog.Unblock
} else {
confirmationDialog = null
userProfilePresenterHelper.unblockUser(coroutineScope, isBlocked)
}
}
UserProfileEvents.ClearConfirmationDialog -> confirmationDialog = null
UserProfileEvents.ClearBlockUserError -> {
isBlocked.value = AsyncData.Success(isBlocked.value.dataOrNull().orFalse())
}
UserProfileEvents.StartDM -> {
coroutineScope.launch {
startDMAction.execute(roomMemberId, startDmActionState)
}
}
UserProfileEvents.ClearStartDMState -> {
startDmActionState.value = AsyncAction.Uninitialized
}
}
}
val userName: String? by produceState(
initialValue = roomMember?.displayName ?: userProfile?.displayName,
key1 = roomMember, key1 = roomMember,
key2 = userProfile,
) { ) {
value = room.userDisplayName(roomMemberId) value = room.userDisplayName(roomMemberId).getOrNull() ?: roomMember?.displayName
.fold(
onSuccess = { it },
onFailure = {
// Fallback to user profile
userProfile?.displayName
}
)
} }
val userAvatar: String? by produceState( val roomUserAvatar: String? by produceState(
initialValue = roomMember?.avatarUrl ?: userProfile?.avatarUrl, initialValue = roomMember?.avatarUrl,
key1 = roomMember, key1 = roomMember,
key2 = userProfile,
) { ) {
value = room.userAvatarUrl(roomMemberId) value = room.userAvatarUrl(roomMemberId).getOrNull() ?: roomMember?.avatarUrl
.fold(
onSuccess = { it },
onFailure = {
// Fallback to user profile
userProfile?.avatarUrl
}
)
} }
return UserProfileState( val userProfileState = userProfilePresenter.present()
userId = roomMemberId,
userName = userName, return userProfileState.copy(
avatarUrl = userAvatar, userName = roomUserName ?: userProfileState.userName,
isBlocked = isBlocked.value, avatarUrl = roomUserAvatar ?: userProfileState.avatarUrl,
startDmActionState = startDmActionState.value,
displayConfirmationDialog = confirmationDialog,
isCurrentUser = isCurrentUser,
dmRoomId = dmRoomId,
canCall = canCall,
eventSink = ::handleEvents
) )
} }
} }

View file

@ -14,7 +14,6 @@ import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.createroom.test.FakeStartDMAction
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState import io.element.android.features.leaveroom.api.aLeaveRoomState
@ -25,6 +24,8 @@ import io.element.android.features.roomdetails.impl.RoomDetailsType
import io.element.android.features.roomdetails.impl.RoomTopicState import io.element.android.features.roomdetails.impl.RoomTopicState
import io.element.android.features.roomdetails.impl.members.aRoomMember import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.userprofile.shared.aUserProfileState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@ -82,7 +83,13 @@ class RoomDetailsPresenterTest {
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService) val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory { val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter { override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter(roomMemberId, matrixClient, room, FakeStartDMAction()) return RoomMemberDetailsPresenter(
roomMemberId = roomMemberId,
room = room,
userProfilePresenterFactory = {
Presenter { aUserProfileState() }
},
)
} }
} }
val featureFlagService = FakeFeatureFlagService( val featureFlagService = FakeFeatureFlagService(

View file

@ -9,27 +9,18 @@ package io.element.android.features.roomdetails.members.details
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
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.aMatrixRoom
import io.element.android.features.roomdetails.impl.members.aRoomMember import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.userprofile.shared.UserProfileEvents import io.element.android.features.userprofile.api.UserProfilePresenterFactory
import io.element.android.features.userprofile.shared.UserProfileState import io.element.android.features.userprofile.shared.aUserProfileState
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.MatrixRoomMembersState
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
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.ui.components.aMatrixUser
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -54,28 +45,26 @@ class RoomMemberDetailsPresenterTest {
} }
val presenter = createRoomMemberDetailsPresenter( val presenter = createRoomMemberDetailsPresenter(
room = room, room = room,
roomMemberId = roomMember.userId
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitFirstItem() val initialState = awaitItem()
assertThat(initialState.userId).isEqualTo(roomMember.userId) assertThat(initialState.userName).isEqualTo("Alice")
assertThat(initialState.userName).isEqualTo(roomMember.displayName) assertThat(initialState.avatarUrl).isEqualTo("Profile avatar url")
assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl)
assertThat(initialState.isBlocked).isEqualTo(AsyncData.Success(roomMember.isIgnored))
assertThat(initialState.dmRoomId).isEqualTo(A_ROOM_ID)
assertThat(initialState.canCall).isFalse()
skipItems(1) skipItems(1)
val loadedState = awaitItem() val nextState = awaitItem()
assertThat(loadedState.userName).isEqualTo("A custom name") assertThat(nextState.userName).isEqualTo("A custom name")
assertThat(loadedState.avatarUrl).isEqualTo("A custom avatar") assertThat(nextState.avatarUrl).isEqualTo("A custom avatar")
} }
} }
@Test @Test
fun `present - will recover when retrieving room member details fails`() = runTest { fun `present - will recover when retrieving room member details fails`() = runTest {
val roomMember = aRoomMember(displayName = "Alice") val roomMember = aRoomMember(
displayName = "Alice",
avatarUrl = "Alice Avatar url",
)
val room = aMatrixRoom( val room = aMatrixRoom(
userDisplayNameResult = { Result.failure(Throwable()) }, userDisplayNameResult = { Result.failure(Throwable()) },
userAvatarUrlResult = { Result.failure(Throwable()) }, userAvatarUrlResult = { Result.failure(Throwable()) },
@ -86,16 +75,13 @@ class RoomMemberDetailsPresenterTest {
val presenter = createRoomMemberDetailsPresenter( val presenter = createRoomMemberDetailsPresenter(
room = room, room = room,
roomMemberId = roomMember.userId
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitFirstItem() val initialState = awaitItem()
assertThat(initialState.userName).isEqualTo(roomMember.displayName) assertThat(initialState.userName).isEqualTo("Alice")
assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl) assertThat(initialState.avatarUrl).isEqualTo("Alice Avatar url")
ensureAllEventsConsumed()
} }
} }
@ -111,238 +97,81 @@ class RoomMemberDetailsPresenterTest {
} }
val presenter = createRoomMemberDetailsPresenter( val presenter = createRoomMemberDetailsPresenter(
room = room, room = room,
roomMemberId = roomMember.userId
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitFirstItem() val initialState = awaitItem()
assertThat(initialState.userName).isEqualTo(roomMember.displayName) assertThat(initialState.userName).isEqualTo("Alice")
assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl) assertThat(initialState.avatarUrl).isEqualTo("Profile avatar url")
ensureAllEventsConsumed()
} }
} }
@Test @Test
fun `present - will fallback to user profile if user is not a member of the room`() = runTest { fun `present - will fallback to user profile if user is not a member of the room`() = runTest {
val bobProfile = aMatrixUser("@bob:server.org", "Bob", avatarUrl = "anAvatarUrl")
val room = aMatrixRoom( val room = aMatrixRoom(
userDisplayNameResult = { Result.failure(Exception("Not a member!")) }, userDisplayNameResult = { Result.failure(Exception("Not a member!")) },
userAvatarUrlResult = { Result.failure(Exception("Not a member!")) }, userAvatarUrlResult = { Result.failure(Exception("Not a member!")) },
getUpdatedMemberResult = { Result.failure(AN_EXCEPTION) }, getUpdatedMemberResult = { Result.failure(AN_EXCEPTION) },
) )
val client = FakeMatrixClient().apply {
givenGetProfileResult(bobProfile.userId, Result.success(bobProfile))
}
val presenter = createRoomMemberDetailsPresenter( val presenter = createRoomMemberDetailsPresenter(
client = client,
room = room, room = room,
roomMemberId = UserId("@bob:server.org")
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(2) val initialState = awaitItem()
val initialState = awaitFirstItem() assertThat(initialState.userName).isEqualTo("Profile user name")
assertThat(initialState.userName).isEqualTo("Bob") assertThat(initialState.avatarUrl).isEqualTo("Profile avatar url")
assertThat(initialState.avatarUrl).isEqualTo("anAvatarUrl")
ensureAllEventsConsumed()
} }
} }
@Test @Test
fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest { fun `present - null cases`() = runTest {
val roomMember = aRoomMember(
displayName = null,
avatarUrl = null,
)
val room = aMatrixRoom(
userDisplayNameResult = { Result.success(null) },
userAvatarUrlResult = { Result.success(null) },
getUpdatedMemberResult = { Result.success(roomMember) },
)
val presenter = createRoomMemberDetailsPresenter( val presenter = createRoomMemberDetailsPresenter(
room = aMatrixRoom( room = room,
getUpdatedMemberResult = { Result.failure(AN_EXCEPTION) }, userProfilePresenterFactory = {
userDisplayNameResult = { Result.success("Alice") }, Presenter {
userAvatarUrlResult = { Result.success("anAvatarUrl") }, aUserProfileState(
) userName = null,
avatarUrl = null,
)
}
},
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitFirstItem() val initialState = awaitItem()
initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = true)) assertThat(initialState.userName).isNull()
assertThat(initialState.avatarUrl).isNull()
val dialogState = awaitItem()
assertThat(dialogState.displayConfirmationDialog).isEqualTo(UserProfileState.ConfirmationDialog.Block)
dialogState.eventSink(UserProfileEvents.ClearConfirmationDialog)
assertThat(awaitItem().displayConfirmationDialog).isNull()
ensureAllEventsConsumed()
} }
} }
@Test
fun `present - BlockUser and UnblockUser without confirmation change the 'blocked' state`() = runTest {
val client = FakeMatrixClient()
val roomMember = aRoomMember()
val presenter = createRoomMemberDetailsPresenter(
room = aMatrixRoom(
getUpdatedMemberResult = { Result.failure(AN_EXCEPTION) },
userDisplayNameResult = { Result.success("Alice") },
userAvatarUrlResult = { Result.success("anAvatarUrl") },
),
client = client,
roomMemberId = roomMember.userId
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
client.emitIgnoreUserList(listOf(roomMember.userId))
assertThat(awaitItem().isBlocked.dataOrNull()).isTrue()
initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
client.emitIgnoreUserList(listOf())
assertThat(awaitItem().isBlocked.dataOrNull()).isFalse()
}
}
@Test
fun `present - BlockUser with error`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenIgnoreUserResult(Result.failure(A_THROWABLE))
val presenter = createRoomMemberDetailsPresenter(
client = matrixClient,
room = aMatrixRoom(
getUpdatedMemberResult = { Result.success(aRoomMember(displayName = "Alice")) },
userDisplayNameResult = { Result.success("Alice") },
userAvatarUrlResult = { Result.success("anAvatarUrl") },
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
skipItems(2)
val errorState = awaitItem()
assertThat(errorState.isBlocked.errorOrNull()).isEqualTo(A_THROWABLE)
// Clear error
initialState.eventSink(UserProfileEvents.ClearBlockUserError)
assertThat(awaitItem().isBlocked).isEqualTo(AsyncData.Success(false))
}
}
@Test
fun `present - UnblockUser with error`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenUnignoreUserResult(Result.failure(A_THROWABLE))
val presenter = createRoomMemberDetailsPresenter(
room = aMatrixRoom(
getUpdatedMemberResult = { Result.success(aRoomMember(displayName = "Alice")) },
userDisplayNameResult = { Result.success("Alice") },
userAvatarUrlResult = { Result.success("anAvatarUrl") },
),
client = matrixClient,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
skipItems(2)
val errorState = awaitItem()
assertThat(errorState.isBlocked.errorOrNull()).isEqualTo(A_THROWABLE)
// Clear error
initialState.eventSink(UserProfileEvents.ClearBlockUserError)
assertThat(awaitItem().isBlocked).isEqualTo(AsyncData.Success(true))
}
}
@Test
fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest {
val presenter = createRoomMemberDetailsPresenter(
room = aMatrixRoom(
getUpdatedMemberResult = { Result.failure(AN_EXCEPTION) },
userDisplayNameResult = { Result.success("Alice") },
userAvatarUrlResult = { Result.success("anAvatarUrl") },
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = true))
val dialogState = awaitItem()
assertThat(dialogState.displayConfirmationDialog).isEqualTo(UserProfileState.ConfirmationDialog.Unblock)
dialogState.eventSink(UserProfileEvents.ClearConfirmationDialog)
assertThat(awaitItem().displayConfirmationDialog).isNull()
ensureAllEventsConsumed()
}
}
@Test
fun `present - start DM action complete scenario`() = runTest {
val startDMAction = FakeStartDMAction()
val presenter = createRoomMemberDetailsPresenter(
room = aMatrixRoom(
getUpdatedMemberResult = { Result.success(aRoomMember(displayName = "Alice")) },
userDisplayNameResult = { Result.success("Alice") },
userAvatarUrlResult = { Result.success("anAvatarUrl") },
),
startDMAction = startDMAction,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java)
val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID)
val startDMFailureResult = AsyncAction.Failure(A_THROWABLE)
// Failure
startDMAction.givenExecuteResult(startDMFailureResult)
initialState.eventSink(UserProfileEvents.StartDM)
assertThat(awaitItem().startDmActionState).isInstanceOf(AsyncAction.Loading::class.java)
skipItems(2)
awaitItem().also { state ->
assertThat(state.startDmActionState).isEqualTo(startDMFailureResult)
state.eventSink(UserProfileEvents.ClearStartDMState)
}
// Success
startDMAction.givenExecuteResult(startDMSuccessResult)
awaitItem().also { state ->
assertThat(state.startDmActionState).isEqualTo(AsyncAction.Uninitialized)
state.eventSink(UserProfileEvents.StartDM)
}
assertThat(awaitItem().startDmActionState).isInstanceOf(AsyncAction.Loading::class.java)
awaitItem().also { state ->
assertThat(state.startDmActionState).isEqualTo(startDMSuccessResult)
}
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1)
return awaitItem()
}
private fun createRoomMemberDetailsPresenter( private fun createRoomMemberDetailsPresenter(
room: MatrixRoom, room: MatrixRoom,
client: MatrixClient = FakeMatrixClient(), userProfilePresenterFactory: UserProfilePresenterFactory = UserProfilePresenterFactory {
roomMemberId: UserId = UserId("@alice:server.org"), Presenter {
startDMAction: StartDMAction = FakeStartDMAction() aUserProfileState(
userName = "Profile user name",
avatarUrl = "Profile avatar url",
)
}
},
): RoomMemberDetailsPresenter { ): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter( return RoomMemberDetailsPresenter(
roomMemberId = roomMemberId, roomMemberId = UserId("@alice:server.org"),
client = client,
room = room, room = room,
startDMAction = startDMAction userProfilePresenterFactory = userProfilePresenterFactory
) )
} }
} }

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details. * Please see LICENSE in the repository root for full details.
*/ */
package io.element.android.features.userprofile.shared package io.element.android.features.userprofile.api
sealed interface UserProfileEvents { sealed interface UserProfileEvents {
data object StartDM : UserProfileEvents data object StartDM : UserProfileEvents

View file

@ -0,0 +1,15 @@
/*
* 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.userprofile.api
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.UserId
fun interface UserProfilePresenterFactory {
fun create(userId: UserId): Presenter<UserProfileState>
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details. * Please see LICENSE in the repository root for full details.
*/ */
package io.element.android.features.userprofile.shared package io.element.android.features.userprofile.api
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData

View file

@ -0,0 +1,24 @@
/*
* 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.userprofile.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.impl.root.UserProfilePresenter
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.UserId
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultUserProfilePresenterFactory @Inject constructor(
private val factory: UserProfilePresenter.Factory,
) : UserProfilePresenterFactory {
override fun create(userId: UserId): Presenter<UserProfileState> = factory.create(userId)
}

View file

@ -1,33 +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.features.userprofile.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.userprofile.impl.root.UserProfilePresenter
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
@Module
@ContributesTo(SessionScope::class)
object UserProfileModule {
@Provides
fun provideUserProfilePresenterFactory(
matrixClient: MatrixClient,
startDMAction: StartDMAction,
): UserProfilePresenter.Factory {
return object : UserProfilePresenter.Factory {
override fun create(userId: UserId): UserProfilePresenter {
return UserProfilePresenter(userId, matrixClient, startDMAction)
}
}
}
}

View file

@ -10,18 +10,20 @@ package io.element.android.features.userprofile.impl.root
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.createroom.api.StartDMAction import io.element.android.features.createroom.api.StartDMAction
import io.element.android.features.userprofile.shared.UserProfileEvents import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.shared.UserProfilePresenterHelper import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.shared.UserProfileState import io.element.android.features.userprofile.api.UserProfileState.ConfirmationDialog
import io.element.android.features.userprofile.shared.UserProfileState.ConfirmationDialog
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -30,6 +32,7 @@ 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.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -41,24 +44,39 @@ class UserProfilePresenter @AssistedInject constructor(
private val client: MatrixClient, private val client: MatrixClient,
private val startDMAction: StartDMAction, private val startDMAction: StartDMAction,
) : Presenter<UserProfileState> { ) : Presenter<UserProfileState> {
@AssistedFactory
interface Factory { interface Factory {
fun create(userId: UserId): UserProfilePresenter fun create(userId: UserId): UserProfilePresenter
} }
private val userProfilePresenterHelper = UserProfilePresenterHelper( @Composable
userId = userId, private fun getDmRoomId(): State<RoomId?> {
client = client, return produceState<RoomId?>(initialValue = null) {
) value = client.findDM(userId)
}
}
@Composable
private fun getCanCall(roomId: RoomId?): State<Boolean> {
return produceState(initialValue = false, roomId) {
value = if (client.isMe(userId)) {
false
} else {
roomId?.let { client.getRoom(it)?.canUserJoinCall(client.sessionId)?.getOrNull() == true }.orFalse()
}
}
}
@Composable @Composable
override fun present(): UserProfileState { override fun present(): UserProfileState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val isCurrentUser = remember { client.isMe(userId) }
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) } var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
var userProfile by remember { mutableStateOf<MatrixUser?>(null) } var userProfile by remember { mutableStateOf<MatrixUser?>(null) }
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val isBlocked: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) } val isBlocked: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
val dmRoomId by userProfilePresenterHelper.getDmRoomId() val dmRoomId by getDmRoomId()
val canCall by userProfilePresenterHelper.getCanCall(dmRoomId) val canCall by getCanCall(dmRoomId)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
client.ignoredUsersFlow client.ignoredUsersFlow
.map { ignoredUsers -> userId in ignoredUsers } .map { ignoredUsers -> userId in ignoredUsers }
@ -77,7 +95,7 @@ class UserProfilePresenter @AssistedInject constructor(
confirmationDialog = ConfirmationDialog.Block confirmationDialog = ConfirmationDialog.Block
} else { } else {
confirmationDialog = null confirmationDialog = null
userProfilePresenterHelper.blockUser(coroutineScope, isBlocked) coroutineScope.blockUser(isBlocked)
} }
} }
is UserProfileEvents.UnblockUser -> { is UserProfileEvents.UnblockUser -> {
@ -85,7 +103,7 @@ class UserProfilePresenter @AssistedInject constructor(
confirmationDialog = ConfirmationDialog.Unblock confirmationDialog = ConfirmationDialog.Unblock
} else { } else {
confirmationDialog = null confirmationDialog = null
userProfilePresenterHelper.unblockUser(coroutineScope, isBlocked) coroutineScope.unblockUser(isBlocked)
} }
} }
UserProfileEvents.ClearConfirmationDialog -> confirmationDialog = null UserProfileEvents.ClearConfirmationDialog -> confirmationDialog = null
@ -110,10 +128,32 @@ class UserProfilePresenter @AssistedInject constructor(
isBlocked = isBlocked.value, isBlocked = isBlocked.value,
startDmActionState = startDmActionState.value, startDmActionState = startDmActionState.value,
displayConfirmationDialog = confirmationDialog, displayConfirmationDialog = confirmationDialog,
isCurrentUser = client.isMe(userId), isCurrentUser = isCurrentUser,
dmRoomId = dmRoomId, dmRoomId = dmRoomId,
canCall = canCall, canCall = canCall,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
private fun CoroutineScope.blockUser(
isBlockedState: MutableState<AsyncData<Boolean>>,
) = launch {
isBlockedState.value = AsyncData.Loading(false)
client.ignoreUser(userId)
.onFailure {
isBlockedState.value = AsyncData.Failure(it, false)
}
// Note: on success, ignoredUsersFlow will emit new item.
}
private fun CoroutineScope.unblockUser(
isBlockedState: MutableState<AsyncData<Boolean>>,
) = launch {
isBlockedState.value = AsyncData.Loading(true)
client.unignoreUser(userId)
.onFailure {
isBlockedState.value = AsyncData.Failure(it, true)
}
// Note: on success, ignoredUsersFlow will emit new item.
}
} }

View file

@ -7,27 +7,29 @@
package io.element.android.features.userprofile.impl package io.element.android.features.userprofile.impl
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.api.StartDMAction import io.element.android.features.createroom.api.StartDMAction
import io.element.android.features.createroom.test.FakeStartDMAction import io.element.android.features.createroom.test.FakeStartDMAction
import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.impl.root.UserProfilePresenter import io.element.android.features.userprofile.impl.root.UserProfilePresenter
import io.element.android.features.userprofile.shared.UserProfileEvents
import io.element.android.features.userprofile.shared.UserProfileState
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.MatrixClient 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.core.UserId
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID 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_THROWABLE
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -47,9 +49,7 @@ class UserProfilePresenterTest {
val presenter = createUserProfilePresenter( val presenter = createUserProfilePresenter(
client = client, client = client,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.userId).isEqualTo(matrixUser.userId) assertThat(initialState.userId).isEqualTo(matrixUser.userId)
assertThat(initialState.userName).isEqualTo(matrixUser.displayName) assertThat(initialState.userName).isEqualTo(matrixUser.displayName)
@ -60,6 +60,70 @@ class UserProfilePresenterTest {
} }
} }
@Test
fun `present - canCall is true when all the conditions are met`() {
testCanCall(
expectedResult = true,
)
}
@Test
fun `present - canCall is false when canUserJoinCall returns false`() {
testCanCall(
canUserJoinCallResult = Result.success(false),
expectedResult = false,
)
}
@Test
fun `present - canCall is false when canUserJoinCall fails`() {
testCanCall(
canUserJoinCallResult = Result.failure(AN_EXCEPTION),
expectedResult = false,
)
}
@Test
fun `present - canCall is false when there is no DM`() {
testCanCall(
dmRoom = null,
expectedResult = false,
)
}
@Test
fun `present - canCall is false when room is not found`() {
testCanCall(
canFindRoom = false,
expectedResult = false,
)
}
private fun testCanCall(
canUserJoinCallResult: Result<Boolean> = Result.success(true),
dmRoom: RoomId? = A_ROOM_ID,
canFindRoom: Boolean = true,
expectedResult: Boolean,
) = runTest {
val room = FakeMatrixRoom(
canUserJoinCallResult = { canUserJoinCallResult },
)
val client = FakeMatrixClient().apply {
if (canFindRoom) {
givenGetRoomResult(A_ROOM_ID, room)
}
givenFindDmResult(dmRoom)
}
val presenter = createUserProfilePresenter(
userId = A_USER_ID_2,
client = client,
)
presenter.test {
val initialState = awaitLastSequentialItem()
assertThat(initialState.canCall).isEqualTo(expectedResult)
}
}
@Test @Test
fun `present - returns empty data in case of failure`() = runTest { fun `present - returns empty data in case of failure`() = runTest {
val client = FakeMatrixClient().apply { val client = FakeMatrixClient().apply {
@ -68,9 +132,7 @@ class UserProfilePresenterTest {
val presenter = createUserProfilePresenter( val presenter = createUserProfilePresenter(
client = client, client = client,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.userId).isEqualTo(A_USER_ID) assertThat(initialState.userId).isEqualTo(A_USER_ID)
assertThat(initialState.userName).isNull() assertThat(initialState.userName).isNull()
@ -82,9 +144,7 @@ class UserProfilePresenterTest {
@Test @Test
fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest { fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest {
val presenter = createUserProfilePresenter() val presenter = createUserProfilePresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = true)) initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = true))
@ -105,9 +165,7 @@ class UserProfilePresenterTest {
client = client, client = client,
userId = A_USER_ID userId = A_USER_ID
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false)) initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue() assertThat(awaitItem().isBlocked.isLoading()).isTrue()
@ -126,9 +184,7 @@ class UserProfilePresenterTest {
val matrixClient = FakeMatrixClient() val matrixClient = FakeMatrixClient()
matrixClient.givenIgnoreUserResult(Result.failure(A_THROWABLE)) matrixClient.givenIgnoreUserResult(Result.failure(A_THROWABLE))
val presenter = createUserProfilePresenter(client = matrixClient) val presenter = createUserProfilePresenter(client = matrixClient)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false)) initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue() assertThat(awaitItem().isBlocked.isLoading()).isTrue()
@ -145,9 +201,7 @@ class UserProfilePresenterTest {
val matrixClient = FakeMatrixClient() val matrixClient = FakeMatrixClient()
matrixClient.givenUnignoreUserResult(Result.failure(A_THROWABLE)) matrixClient.givenUnignoreUserResult(Result.failure(A_THROWABLE))
val presenter = createUserProfilePresenter(client = matrixClient) val presenter = createUserProfilePresenter(client = matrixClient)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = false)) initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue() assertThat(awaitItem().isBlocked.isLoading()).isTrue()
@ -162,9 +216,7 @@ class UserProfilePresenterTest {
@Test @Test
fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest { fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest {
val presenter = createUserProfilePresenter() val presenter = createUserProfilePresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = true)) initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = true))
@ -182,9 +234,7 @@ class UserProfilePresenterTest {
fun `present - start DM action complete scenario`() = runTest { fun `present - start DM action complete scenario`() = runTest {
val startDMAction = FakeStartDMAction() val startDMAction = FakeStartDMAction()
val presenter = createUserProfilePresenter(startDMAction = startDMAction) val presenter = createUserProfilePresenter(startDMAction = startDMAction)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java)
val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID) val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID)

View file

@ -1,67 +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.features.userprofile.shared
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import io.element.android.libraries.architecture.AsyncData
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class UserProfilePresenterHelper(
private val userId: UserId,
private val client: MatrixClient,
) {
@Composable
fun getDmRoomId(): State<RoomId?> {
return produceState<RoomId?>(initialValue = null) {
value = client.findDM(userId)
}
}
@Composable
fun getCanCall(roomId: RoomId?): State<Boolean> {
return produceState(initialValue = false, roomId) {
value = if (client.isMe(userId)) {
false
} else {
roomId?.let { client.getRoom(it)?.canUserJoinCall(client.sessionId)?.getOrNull() == true }.orFalse()
}
}
}
fun blockUser(
scope: CoroutineScope,
isBlockedState: MutableState<AsyncData<Boolean>>,
) = scope.launch {
isBlockedState.value = AsyncData.Loading(false)
client.ignoreUser(userId)
.onFailure {
isBlockedState.value = AsyncData.Failure(it, false)
}
// Note: on success, ignoredUserList will be updated.
}
fun unblockUser(
scope: CoroutineScope,
isBlockedState: MutableState<AsyncData<Boolean>>,
) = scope.launch {
isBlockedState.value = AsyncData.Loading(true)
client.unignoreUser(userId)
.onFailure {
isBlockedState.value = AsyncData.Failure(it, true)
}
// Note: on success, ignoredUserList will be updated.
}
}

View file

@ -8,6 +8,8 @@
package io.element.android.features.userprofile.shared package io.element.android.features.userprofile.shared
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId

View file

@ -21,6 +21,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs
import io.element.android.features.userprofile.shared.blockuser.BlockUserSection import io.element.android.features.userprofile.shared.blockuser.BlockUserSection
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView

View file

@ -9,9 +9,9 @@ package io.element.android.features.userprofile.shared.blockuser
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.shared.R import io.element.android.features.userprofile.shared.R
import io.element.android.features.userprofile.shared.UserProfileEvents
import io.element.android.features.userprofile.shared.UserProfileState
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
@Composable @Composable

View file

@ -14,9 +14,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.shared.R import io.element.android.features.userprofile.shared.R
import io.element.android.features.userprofile.shared.UserProfileEvents
import io.element.android.features.userprofile.shared.UserProfileState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
@ -34,23 +34,24 @@ fun BlockUserSection(
state: UserProfileState, state: UserProfileState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isBlocked = state.isBlocked
PreferenceCategory( PreferenceCategory(
modifier = modifier, modifier = modifier,
showTopDivider = false, showTopDivider = false,
) { ) {
when (state.isBlocked) { when (isBlocked) {
is AsyncData.Failure -> PreferenceBlockUser(isBlocked = state.isBlocked.prevData, isLoading = false, eventSink = state.eventSink) is AsyncData.Failure -> PreferenceBlockUser(isBlocked = isBlocked.prevData, isLoading = false, eventSink = state.eventSink)
is AsyncData.Loading -> PreferenceBlockUser(isBlocked = state.isBlocked.prevData, isLoading = true, eventSink = state.eventSink) is AsyncData.Loading -> PreferenceBlockUser(isBlocked = isBlocked.prevData, isLoading = true, eventSink = state.eventSink)
is AsyncData.Success -> PreferenceBlockUser(isBlocked = state.isBlocked.data, isLoading = false, eventSink = state.eventSink) is AsyncData.Success -> PreferenceBlockUser(isBlocked = isBlocked.data, isLoading = false, eventSink = state.eventSink)
AsyncData.Uninitialized -> PreferenceBlockUser(isBlocked = null, isLoading = true, eventSink = state.eventSink) AsyncData.Uninitialized -> PreferenceBlockUser(isBlocked = null, isLoading = true, eventSink = state.eventSink)
} }
} }
if (state.isBlocked is AsyncData.Failure) { if (isBlocked is AsyncData.Failure) {
RetryDialog( RetryDialog(
content = stringResource(CommonStrings.error_unknown), content = stringResource(CommonStrings.error_unknown),
onDismiss = { state.eventSink(UserProfileEvents.ClearBlockUserError) }, onDismiss = { state.eventSink(UserProfileEvents.ClearBlockUserError) },
onRetry = { onRetry = {
val event = when (state.isBlocked.prevData) { val event = when (isBlocked.prevData) {
true -> UserProfileEvents.UnblockUser(needsConfirmation = false) true -> UserProfileEvents.UnblockUser(needsConfirmation = false)
false -> UserProfileEvents.BlockUser(needsConfirmation = false) false -> UserProfileEvents.BlockUser(needsConfirmation = false)
// null case Should not happen // null case Should not happen

View file

@ -13,9 +13,9 @@ import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.shared.R import io.element.android.features.userprofile.shared.R
import io.element.android.features.userprofile.shared.UserProfileEvents
import io.element.android.features.userprofile.shared.UserProfileState
import io.element.android.features.userprofile.shared.UserProfileView import io.element.android.features.userprofile.shared.UserProfileView
import io.element.android.features.userprofile.shared.aUserProfileState import io.element.android.features.userprofile.shared.aUserProfileState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData

View file

@ -10,9 +10,9 @@ package io.element.android.features.userprofile.shared.blockuser
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.shared.R import io.element.android.features.userprofile.shared.R
import io.element.android.features.userprofile.shared.UserProfileEvents
import io.element.android.features.userprofile.shared.UserProfileState
import io.element.android.features.userprofile.shared.aUserProfileState import io.element.android.features.userprofile.shared.aUserProfileState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder