diff --git a/changelog.d/300.feature b/changelog.d/300.feature new file mode 100644 index 0000000000..fba672e002 --- /dev/null +++ b/changelog.d/300.feature @@ -0,0 +1 @@ +Implement room member details screen diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt index 4a46f3370c..5778eae96e 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -29,10 +29,13 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.features.roomdetails.impl.members.RoomMemberListNode +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.RoomMember import kotlinx.parcelize.Parcelize @ContributesNode(RoomScope::class) @@ -54,6 +57,9 @@ class RoomDetailsFlowNode @AssistedInject constructor( @Parcelize object RoomMemberList : NavTarget + + @Parcelize + data class RoomMemberDetails(val roomMember: RoomMember) : NavTarget } interface Callback : Plugin { @@ -69,7 +75,17 @@ class RoomDetailsFlowNode @AssistedInject constructor( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { NavTarget.RoomDetails -> createNode(buildContext, listOf(callback)) - NavTarget.RoomMemberList -> createNode(buildContext) + NavTarget.RoomMemberList -> { + val callback = object : RoomMemberListNode.Callback { + override fun openRoomMemberDetails(roomMember: RoomMember) { + backstack.push(NavTarget.RoomMemberDetails(roomMember)) + } + } + createNode(buildContext, listOf(callback)) + } + is NavTarget.RoomMemberDetails -> { + createNode(buildContext, listOf(RoomMemberDetailsNode.Inputs(navTarget.roomMember))) + } } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index d5b2a4a9e4..a4e7c8f77e 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -87,7 +87,7 @@ fun RoomDetailsView( roomAlias = state.roomAlias ) - ShareSection(onShareRoom = onShareRoom) + ShareSection(onShareUser = onShareRoom) if (state.roomTopic != null) { TopicSection(roomTopic = state.roomTopic) @@ -127,12 +127,12 @@ fun RoomDetailsView( } @Composable -internal fun ShareSection(onShareRoom: () -> Unit, modifier: Modifier = Modifier) { +internal fun ShareSection(onShareUser: () -> Unit, modifier: Modifier = Modifier) { PreferenceCategory(modifier = modifier) { PreferenceText( title = stringResource(R.string.screen_room_details_share_room_title), icon = Icons.Outlined.Share, - onClick = onShareRoom, + onClick = onShareUser, ) } } @@ -172,7 +172,6 @@ internal fun TopicSection(roomTopic: String, modifier: Modifier = Modifier) { color = MaterialTheme.colorScheme.tertiary ) } - } @Composable diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberBindsModule.kt similarity index 61% rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt rename to features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberBindsModule.kt index 49c98374f2..e539e9070a 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberBindsModule.kt @@ -19,17 +19,35 @@ package io.element.android.features.roomdetails.impl.di import com.squareup.anvil.annotations.ContributesTo import dagger.Binds import dagger.Module +import dagger.Provides import io.element.android.features.roomdetails.impl.members.RoomMatrixUserDataSource +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.userlist.api.MatrixUserDataSource import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMember import javax.inject.Named @Module @ContributesTo(RoomScope::class) -interface RoomMemberModule { +interface RoomMemberBindsModule { @Binds @Named("RoomMembers") fun bindRoomMemberUserListDataSource(dataSource: RoomMatrixUserDataSource): MatrixUserDataSource - +} + +@Module +@ContributesTo(RoomScope::class) +object RoomMemberProvidesModule { + @Provides + fun provideRoomMemberDetailsPresenterFactory( + room: MatrixRoom, + ): RoomMemberDetailsPresenter.Factory { + return object : RoomMemberDetailsPresenter.Factory { + override fun create(roomMember: RoomMember): RoomMemberDetailsPresenter { + return RoomMemberDetailsPresenter(room, roomMember) + } + } + } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt index 0f65b4657b..59f13115f0 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt @@ -21,10 +21,14 @@ 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 dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.roomdetails.impl.RoomDetailsFlowNode import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.ui.model.MatrixUser import timber.log.Timber @@ -32,11 +36,23 @@ import timber.log.Timber class RoomMemberListNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, + private val room: MatrixRoom, private val presenter: RoomMemberListPresenter, ) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun openRoomMemberDetails(roomMember: RoomMember) + } + + private val callback = plugins().first() + private fun onUserSelected(matrixUser: MatrixUser) { - Timber.d("TODO: implement user selection. User: $matrixUser") + val member = room.getMember(matrixUser.id) + if (member != null) { + callback.openRoomMemberDetails(member) + } else { + Timber.e("Could find room member ${matrixUser.id} in room ${room.roomId}") + } } @Composable diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt new file mode 100644 index 0000000000..2ce6688925 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt @@ -0,0 +1,22 @@ +/* + * 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.roomdetails.impl.members.details + +// TODO Add your events or remove the file completely if no events +sealed interface RoomMemberDetailsEvents { + object MyEvent : RoomMemberDetailsEvents +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt new file mode 100644 index 0000000000..fe7f816a5a --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt @@ -0,0 +1,80 @@ +/* + * 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.roomdetails.impl.members.details + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +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 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.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.libraries.matrix.api.room.RoomMember +import timber.log.Timber +import io.element.android.libraries.androidutils.R as AndroidUtilsR + +@ContributesNode(RoomScope::class) +class RoomMemberDetailsNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: RoomMemberDetailsPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + + data class Inputs( + val member: RoomMember, + ) : NodeInputs + + private val inputs = inputs() + private val presenter = presenterFactory.create(inputs.member) + + private fun onShareUser(context: Context) { + val permalinkResult = PermalinkBuilder.permalinkForUser(UserId(inputs.member.userId)) + permalinkResult.onSuccess { permalink -> + startSharePlainTextIntent( + context = context, + activityResultLauncher = null, + chooserTitle = context.getString(R.string.screen_room_details_share_room_title), + text = permalink, + noActivityFoundMessage = context.getString(AndroidUtilsR.string.error_no_compatible_app_found) + ) + }.onFailure { + Timber.e(it) + } + } + + @Composable + override fun View(modifier: Modifier) { + val context = LocalContext.current + val state = presenter.present() + RoomMemberDetailsView( + state = state, + modifier = modifier, + goBack = { navigateUp() }, + onShareUser = { onShareUser(context) } + ) + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt new file mode 100644 index 0000000000..0e997c4499 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt @@ -0,0 +1,51 @@ +/* + * 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.roomdetails.impl.members.details + +import androidx.compose.runtime.Composable +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMember + +class RoomMemberDetailsPresenter @AssistedInject constructor( + private val room: MatrixRoom, + @Assisted private val roomMember: RoomMember, +) : Presenter { + + interface Factory { + fun create(roomMember: RoomMember): RoomMemberDetailsPresenter + } + + @Composable + override fun present(): RoomMemberDetailsState { + +// fun handleEvents(event: RoomMemberDetailsEvents) { +// when (event) { +// } +// } + + return RoomMemberDetailsState( + userId = roomMember.userId, + userName = roomMember.displayName, + avatarUrl = roomMember.avatarUrl, + isBlocked = roomMember.isIgnored, +// eventSink = ::handleEvents + ) + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt new file mode 100644 index 0000000000..d9e3f949e7 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt @@ -0,0 +1,25 @@ +/* + * 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.roomdetails.impl.members.details + +data class RoomMemberDetailsState( + val userId: String, + val userName: String?, + val avatarUrl: String?, + val isBlocked: Boolean, +// val eventSink: (RoomMemberDetailsEvents) -> Unit +) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt new file mode 100644 index 0000000000..c719ab7a26 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt @@ -0,0 +1,37 @@ +/* + * 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.roomdetails.impl.members.details + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class RoomMemberDetailsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aRoomMemberDetailsState(), + aRoomMemberDetailsState().copy(userName = null), + aRoomMemberDetailsState().copy(isBlocked = true), + // Add other states here + ) +} + +fun aRoomMemberDetailsState() = RoomMemberDetailsState( + userId = "@daniel:domain.com", + userName = "Daniel", + avatarUrl = null, + isBlocked = false, +// eventSink = {}, +) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt new file mode 100644 index 0000000000..d7a97e5fdc --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt @@ -0,0 +1,177 @@ +/* + * 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.roomdetails.impl.members.details + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.roomdetails.impl.R +import io.element.android.libraries.designsystem.ElementTextStyles +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.components.preferences.PreferenceText +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.LocalColors +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.ui.strings.R as StringR + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RoomMemberDetailsView( + state: RoomMemberDetailsState, + onShareUser: () -> Unit, + goBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar(title = { }, navigationIcon = { BackButton(onClick = goBack) }) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + HeaderSection( + avatarUrl = state.avatarUrl, + userId = state.userId, + userName = state.userName, + ) + + ShareSection(onShareUser = onShareUser) + + SendMessageSection(onSendMessage = { + // TODO implement send DM + }) + + BlockSection(isBlocked = state.isBlocked, onToggleBlock = { + // TODO implement block & unblock + }) + } + } +} + +@Composable +internal fun HeaderSection( + avatarUrl: String?, + userId: String, + userName: String?, + modifier: Modifier = Modifier +) { + Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Box(modifier = Modifier.size(70.dp)) { + Avatar( + avatarData = AvatarData(userId, userName, avatarUrl, AvatarSize.HUGE), + modifier = Modifier.fillMaxSize() + ) + } + Spacer(modifier = Modifier.height(30.dp)) + if (userName != null) { + Text(userName, style = ElementTextStyles.Bold.title1) + Spacer(modifier = Modifier.height(8.dp)) + } + Text(userId, style = ElementTextStyles.Regular.body, color = MaterialTheme.colorScheme.secondary) + Spacer(Modifier.height(32.dp)) + } +} + +@Composable +internal fun ShareSection(onShareUser: () -> Unit, modifier: Modifier = Modifier) { + PreferenceCategory(modifier = modifier) { + PreferenceText( + title = stringResource(StringR.string.action_share), + icon = Icons.Outlined.Share, + onClick = onShareUser, + ) + } +} + +@Composable +internal fun SendMessageSection(onSendMessage: () -> Unit, modifier: Modifier = Modifier) { + PreferenceCategory(modifier = modifier) { + PreferenceText( + title = stringResource(StringR.string.action_send_message), + icon = Icons.Outlined.ChatBubbleOutline, + onClick = onSendMessage, + ) + } +} + +@Composable +internal fun BlockSection(isBlocked: Boolean, onToggleBlock: () -> Unit, modifier: Modifier = Modifier) { + PreferenceCategory(showDivider = false, modifier = modifier) { + if (isBlocked) { + PreferenceText( + title = stringResource(R.string.screen_dm_details_unblock_user), + icon = Icons.Outlined.Block, + ) + } else { + PreferenceText( + title = stringResource(R.string.screen_dm_details_block_user), + icon = Icons.Outlined.Block, + tintColor = LocalColors.current.textActionCritical, + ) + } + } +} + +@Preview +@Composable +fun RoomMemberDetailsViewLightPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun RoomMemberDetailsViewDarkPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: RoomMemberDetailsState) { + RoomMemberDetailsView( + state = state, + onShareUser = {}, + goBack = {}, + ) +} diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt index 41404b7354..6292f877d7 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt @@ -233,7 +233,8 @@ fun aRoomMember( membership: RoomMembershipState = RoomMembershipState.JOIN, isNameAmbiguous: Boolean = false, powerLevel: Long = 0L, - normalizedPowerLevel: Long = 0L + normalizedPowerLevel: Long = 0L, + isIgnored: Boolean = false, ) = RoomMember( userId = userId.value, displayName = displayName, @@ -242,4 +243,5 @@ fun aRoomMember( isNameAmbiguous = isNameAmbiguous, powerLevel = powerLevel, normalizedPowerLevel = normalizedPowerLevel, + isIgnored = isIgnored, ) diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt index 3564daa5f1..1263ecff5d 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt @@ -29,10 +29,12 @@ import io.element.android.features.userlist.impl.DefaultUserListPresenter import io.element.android.features.userlist.test.FakeMatrixUserDataSource import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.ui.components.aMatrixUser +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import okhttp3.internal.toImmutableList import org.junit.Test +@ExperimentalCoroutinesApi class RoomMemberListPresenterTests { @Test diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt new file mode 100644 index 0000000000..de4904fb7f --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt @@ -0,0 +1,48 @@ +/* + * 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.roomdetails.members.details + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth +import io.element.android.features.roomdetails.aMatrixRoom +import io.element.android.features.roomdetails.aRoomMember +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@ExperimentalCoroutinesApi +class RoomMemberDetailsPresenterTests { + + @Test + fun `present - returns the room member's data`() = runTest { + val room = aMatrixRoom() + val roomMember = aRoomMember(displayName = "Alice") + val presenter = RoomMemberDetailsPresenter(room, roomMember) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + Truth.assertThat(initialState.userId).isEqualTo(roomMember.userId) + Truth.assertThat(initialState.userName).isEqualTo(roomMember.displayName) + Truth.assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl) + Truth.assertThat(initialState.isBlocked).isEqualTo(roomMember.isIgnored) + } + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt index e31b8063df..9ff8c1d76d 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt @@ -20,7 +20,11 @@ import io.element.android.libraries.matrix.api.BuildConfig import java.io.Serializable @JvmInline -value class RoomId(val value: String) : Serializable +value class RoomId(val value: String) : Serializable { + override fun toString(): String { + return value + } +} fun String.asRoomId() = if (BuildConfig.DEBUG && !MatrixPatterns.isRoomId(this)) { error("`$this` is not a valid room Id") diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/UserId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/UserId.kt index 46adcdd59c..93810f8815 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/UserId.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/UserId.kt @@ -20,7 +20,11 @@ import io.element.android.libraries.matrix.api.BuildConfig import java.io.Serializable @JvmInline -value class UserId(val value: String) : Serializable +value class UserId(val value: String) : Serializable { + override fun toString(): String { + return value + } +} fun String.asUserId() = if (BuildConfig.DEBUG && !MatrixPatterns.isUserId(this)) { error("`$this` is not a valid user Id") diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt index 92e2bcef1d..2d8456be38 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt @@ -19,8 +19,14 @@ package io.element.android.libraries.matrix.api.permalink import io.element.android.libraries.matrix.api.config.MatrixConfiguration import io.element.android.libraries.matrix.api.core.MatrixPatterns import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId object PermalinkBuilder { + + private const val ROOM_PATH = "room/" + private const val USER_PATH = "user/" + private const val GROUP_PATH = "group/" + private val permalinkBaseUrl get() = (MatrixConfiguration.clientPermalinkBaseUrl ?: MatrixConfiguration.matrixToPermalinkBaseUrl).also { var baseUrl = it if (!baseUrl.endsWith("/")) { @@ -31,6 +37,21 @@ object PermalinkBuilder { } } + fun permalinkForUser(userId: UserId): Result { + return if (MatrixPatterns.isUserId(userId.value)) { + val url = buildString { + append(permalinkBaseUrl) + if (!isMatrixTo()) { + append(USER_PATH) + } + append(userId.value) + } + Result.success(url) + } else { + Result.failure(PermalinkBuilderError.InvalidRoomAlias) + } + } + fun permalinkForRoomAlias(roomAlias: String): Result { return if (MatrixPatterns.isRoomAlias(roomAlias)) { Result.success(permalinkForRoomAliasOrId(roomAlias)) @@ -49,10 +70,18 @@ object PermalinkBuilder { private fun permalinkForRoomAliasOrId(value: String): String { val id = escapeId(value) - return permalinkBaseUrl + id + return buildString { + append(permalinkBaseUrl) + if (!isMatrixTo()) { + append(ROOM_PATH) + } + append(id) + } } private fun escapeId(value: String) = value.replace("/", "%2F") + + private fun isMatrixTo(): Boolean = permalinkBaseUrl.startsWith(MatrixConfiguration.matrixToPermalinkBaseUrl) } sealed class PermalinkBuilderError : Throwable() { diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 60ff09a15b..9a97fcc9cf 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.api.room import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import kotlinx.coroutines.flow.Flow import java.io.Closeable @@ -38,6 +39,8 @@ interface MatrixRoom: Closeable { suspend fun memberCount(): Int + fun getMember(userId: UserId): RoomMember? + fun syncUpdateFlow(): Flow fun timeline(): MatrixTimeline diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt index 66de9bd627..fcb83553c5 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt @@ -16,6 +16,10 @@ package io.element.android.libraries.matrix.api.room +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize data class RoomMember( val userId: String, val displayName: String?, @@ -23,9 +27,11 @@ data class RoomMember( val membership: RoomMembershipState, val isNameAmbiguous: Boolean, val powerLevel: Long, - val normalizedPowerLevel: Long -) + val normalizedPowerLevel: Long, + val isIgnored: Boolean, +) : Parcelable -enum class RoomMembershipState { +@Parcelize +enum class RoomMembershipState : Parcelable { BAN, INVITE, JOIN, KNOCK, LEAVE } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomMemberMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomMemberMapper.kt index ebc93780ee..1fe0de6080 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomMemberMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomMemberMapper.kt @@ -32,6 +32,7 @@ object RoomMemberMapper { roomMember.isNameAmbiguous(), roomMember.powerLevel(), roomMember.normalizedPowerLevel(), + roomMember.isIgnored(), ) fun mapMembership(membershipState: RustMembershipState): RoomMembershipState = diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index fd5a63cb3b..67cddeee00 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.matrix.api.core.EventId 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.api.room.RoomMember import io.element.android.libraries.matrix.api.timeline.MatrixTimeline @@ -68,6 +69,10 @@ class RustMatrixRoom( return members().size } + override fun getMember(userId: UserId): RoomMember? { + return cachedMembers.firstOrNull { it.userId == userId.value } + } + override fun syncUpdateFlow(): Flow { return slidingSyncUpdateFlow .filter { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 1c6d580a3c..f213da2358 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.test.room import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.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.api.room.RoomMember import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -85,6 +86,10 @@ class FakeMatrixRoom( } } + override fun getMember(userId: UserId): RoomMember? { + return members.firstOrNull { it.userId == userId.value } + } + override suspend fun sendMessage(message: String): Result { delay(100) return Result.success(Unit) diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index de11a74eac..fcade4a62b 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -121,15 +121,60 @@ "Are you sure that you want to leave this room? This room is not public and you will not be able to rejoin without an invite." "Are you sure that you want to leave the room?" "%1$s Android" + "Call" + "Listening for events" + "Noisy notifications" + "Silent notifications" + "** Failed to send - please open room" + "Join" + "Reject" + "New Messages" + "Mark as read" + "Quick reply" + "Me" + "You are viewing the notification! Click me!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + "%1$s and %2$s" + "%1$s in %2$s" + "%1$s in %2$s and %3$s" "%1$d member" "%1$d members" + + "%1$s: %2$d message" + "%1$s: %2$d messages" + + + "%d notification" + "%d notifications" + + + "%d invitation" + "%d invitations" + + + "%d new message" + "%d new messages" + + + "%d unread notified message" + "%d unread notified messages" + + + "%d room" + "%d rooms" + "%1$d room change" "%1$d room changes" "Rageshake to report bug" + "Choose how to receive notifications" + "Background synchronization" + "Google Services" + "No valid Google Play Services found. Notifications may not work properly." "You seem to be shaking the phone in frustration. Would you like to open the bug report screen?" "This message will be reported to your homeserver’s administrator. They will not be able to read any encrypted messages." "Reason for reporting this content" diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9af1f155a8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e6b4eaf1009581383dc6b95f1e27c3f4f94f63ef9e6b1875521ba5249f449ce9 +size 28705 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..75a3ccc4ee --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32b9585ac785804c27eba9a630d5634c79b08b276a11607ea80c83eacccb59a0 +size 26252 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..479406f955 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:312a9c09176df3772386c5bb6f1d6945ae2638101e790a991e6becaec2440b8a +size 29618 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ea22da115e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf5bef51ccb02f801be411b46859f86d5ff0a571978763b589135eb12f1ec60e +size 28265 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a11a39bb0e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:625cad77761037dbf600a1ce7635257051d66138ffbde2663f94550a96d3007c +size 25872 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b982a61f9c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c210546e81ac92d6a3f2583dd3443e0314f2259aa455d1ecdf6da77048fd861 +size 28733