Open user profile and room with event from permalink

This commit is contained in:
Benoit Marty 2024-05-01 10:15:54 +02:00 committed by Benoit Marty
parent 682fd45e00
commit 0476bd95c8
93 changed files with 1499 additions and 370 deletions

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2024 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.userprofile.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultUserProfileEntryPoint @Inject constructor() : UserProfileEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): UserProfileEntryPoint.NodeBuilder {
return object : UserProfileEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
override fun params(params: UserProfileEntryPoint.Params): UserProfileEntryPoint.NodeBuilder {
plugins += params
return this
}
override fun callback(callback: UserProfileEntryPoint.Callback): UserProfileEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<UserProfileFlowNode>(buildContext, plugins)
}
}
}
}

View file

@ -0,0 +1,106 @@
/*
* Copyright (c) 2024 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.userprofile.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.userprofile.shared.avatar.AvatarPreviewNode
import io.element.android.features.userprofile.impl.root.UserProfileNode
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
class UserProfileFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BaseFlowNode<UserProfileFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data class AvatarPreview(val name: String, val avatarUrl: String) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
val callback = object : UserProfileNodeHelper.Callback {
override fun openAvatarPreview(username: String, avatarUrl: String) {
backstack.push(NavTarget.AvatarPreview(username, avatarUrl))
}
override fun onStartDM(roomId: RoomId) {
plugins<UserProfileEntryPoint.Callback>().forEach { it.onOpenRoom(roomId) }
}
}
val params = UserProfileNode.UserProfileInputs(userId = inputs<UserProfileEntryPoint.Params>().userId)
createNode<UserProfileNode>(buildContext, listOf(callback, params))
}
is NavTarget.AvatarPreview -> {
// We need to fake the MimeType here for the viewer to work.
val mimeType = MimeTypes.Images
val input = MediaViewerNode.Inputs(
mediaInfo = MediaInfo(
name = navTarget.name,
mimeType = mimeType,
formattedFileSize = "",
fileExtension = ""
),
mediaSource = MediaSource(url = navTarget.avatarUrl),
thumbnailSource = null,
canDownload = false,
canShare = false,
)
createNode<AvatarPreviewNode>(buildContext, listOf(input))
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
}

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2024 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.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

@ -0,0 +1,96 @@
/*
* Copyright (c) 2024 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.userprofile.impl.root
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
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
import io.element.android.features.userprofile.shared.UserProfileView
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
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
@ContributesNode(SessionScope::class)
class UserProfileNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val analyticsService: AnalyticsService,
private val permalinkBuilder: PermalinkBuilder,
presenterFactory: UserProfilePresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class UserProfileInputs(
val userId: UserId
) : NodeInputs
private val inputs = inputs<UserProfileInputs>()
private val callback = inputs<UserProfileNodeHelper.Callback>()
private val presenter = presenterFactory.create(inputs.userId)
private val userProfileNodeHelper = UserProfileNodeHelper(inputs.userId)
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.User))
}
)
}
@Composable
override fun View(modifier: Modifier) {
val context = LocalContext.current
fun onShareUser() {
userProfileNodeHelper.onShareUser(context, permalinkBuilder)
}
fun onStartDM(roomId: RoomId) {
callback.onStartDM(roomId)
}
val state = presenter.present()
LaunchedEffect(state.startDmActionState) {
val result = state.startDmActionState
if (result is AsyncAction.Success) {
onStartDM(result.data)
}
}
UserProfileView(
state = state,
modifier = modifier,
goBack = this::navigateUp,
onShareUser = ::onShareUser,
onDMStarted = ::onStartDM,
openAvatarPreview = callback::openAvatarPreview,
)
}
}

View file

@ -0,0 +1,124 @@
/*
* Copyright (c) 2024 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.userprofile.impl.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.userprofile.shared.UserProfileEvents
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.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.user.MatrixUser
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class UserProfilePresenter @AssistedInject constructor(
@Assisted private val userId: UserId,
private val client: MatrixClient,
private val startDMAction: StartDMAction,
) : Presenter<UserProfileState> {
interface Factory {
fun create(userId: UserId): UserProfilePresenter
}
private val userProfilePresenterHelper = UserProfilePresenterHelper(
userId = userId,
client = client,
)
@Composable
override fun present(): UserProfileState {
val coroutineScope = rememberCoroutineScope()
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
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) }
LaunchedEffect(Unit) {
client.ignoredUsersFlow
.map { ignoredUsers -> userId in ignoredUsers }
.distinctUntilChanged()
.onEach { isBlocked.value = AsyncData.Success(it) }
.launchIn(this)
}
LaunchedEffect(Unit) {
userProfile = client.getProfile(userId).getOrNull()
}
fun handleEvents(event: UserProfileEvents) {
when (event) {
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(userId, startDmActionState)
}
}
UserProfileEvents.ClearStartDMState -> {
startDmActionState.value = AsyncAction.Uninitialized
}
}
}
return UserProfileState(
userId = userId,
userName = userProfile?.displayName,
avatarUrl = userProfile?.avatarUrl,
isBlocked = isBlocked.value,
startDmActionState = startDmActionState.value,
displayConfirmationDialog = confirmationDialog,
isCurrentUser = client.isMe(userId),
eventSink = ::handleEvents
)
}
}

View file

@ -0,0 +1,238 @@
/*
* Copyright (c) 2024 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.userprofile.impl
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
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.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.AsyncData
import io.element.android.libraries.matrix.api.MatrixClient
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.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.libraries.matrix.ui.components.aMatrixUser
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@ExperimentalCoroutinesApi
class UserProfilePresenterTests {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - returns the user profile data`() = runTest {
val matrixUser = aMatrixUser(A_USER_ID.value, "Alice", "anAvatarUrl")
val client = FakeMatrixClient().apply {
givenGetProfileResult(A_USER_ID, Result.success(matrixUser))
}
val presenter = createUserProfilePresenter(
client = client,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.userId).isEqualTo(matrixUser.userId)
assertThat(initialState.userName).isEqualTo(matrixUser.displayName)
assertThat(initialState.avatarUrl).isEqualTo(matrixUser.avatarUrl)
assertThat(initialState.isBlocked).isEqualTo(AsyncData.Success(false))
}
}
@Test
fun `present - returns empty data in case of failure`() = runTest {
val client = FakeMatrixClient().apply {
givenGetProfileResult(A_USER_ID, Result.failure(AN_EXCEPTION))
}
val presenter = createUserProfilePresenter(
client = client,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.userId).isEqualTo(A_USER_ID)
assertThat(initialState.userName).isNull()
assertThat(initialState.avatarUrl).isNull()
assertThat(initialState.isBlocked).isEqualTo(AsyncData.Success(false))
}
}
@Test
fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest {
val presenter = createUserProfilePresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = true))
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 presenter = createUserProfilePresenter(
client = client,
userId = A_USER_ID
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
client.emitIgnoreUserList(listOf(A_USER_ID))
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 = createUserProfilePresenter(client = matrixClient)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
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 = createUserProfilePresenter(client = matrixClient)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
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 = createUserProfilePresenter()
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 = createUserProfilePresenter(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)
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 createUserProfilePresenter(
client: MatrixClient = FakeMatrixClient(),
userId: UserId = UserId("@alice:server.org"),
startDMAction: StartDMAction = FakeStartDMAction()
): UserProfilePresenter {
return UserProfilePresenter(
userId = userId,
client = client,
startDMAction = startDMAction
)
}
}