Room navigation : make it working with RoomDirectory

This commit is contained in:
ganfra 2024-04-10 15:14:59 +02:00
parent fc20b7399a
commit bf7a94cc93
20 changed files with 226 additions and 163 deletions

View file

@ -42,8 +42,8 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.loggedin.LoggedInNode import io.element.android.appnav.loggedin.LoggedInNode
import io.element.android.appnav.room.RoomFlowNode import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService import io.element.android.features.ftue.api.state.FtueService
@ -55,6 +55,7 @@ import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint
@ -83,6 +84,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
import java.util.Optional
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class LoggedInFlowNode @AssistedInject constructor( class LoggedInFlowNode @AssistedInject constructor(
@ -214,6 +216,7 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data class Room( data class Room(
val roomId: RoomId, val roomId: RoomId,
val roomDescription: RoomDescription? = null,
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages
) : NavTarget ) : NavTarget
@ -304,7 +307,11 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings)) backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings))
} }
} }
val inputs = RoomFlowNode.Inputs(roomId = navTarget.roomId, initialElement = navTarget.initialElement) val inputs = RoomFlowNode.Inputs(
roomId = navTarget.roomId,
roomDescription = Optional.ofNullable(navTarget.roomDescription),
initialElement = navTarget.initialElement
)
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback)) createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback))
} }
is NavTarget.Settings -> { is NavTarget.Settings -> {
@ -375,8 +382,12 @@ class LoggedInFlowNode @AssistedInject constructor(
NavTarget.RoomDirectorySearch -> { NavTarget.RoomDirectorySearch -> {
roomDirectoryEntryPoint.nodeBuilder(this, buildContext) roomDirectoryEntryPoint.nodeBuilder(this, buildContext)
.callback(object : RoomDirectoryEntryPoint.Callback { .callback(object : RoomDirectoryEntryPoint.Callback {
override fun onOpenRoom(roomId: RoomId) { override fun onRoomJoined(roomId: RoomId) {
coroutineScope.launch { attachRoom(roomId) } backstack.push(NavTarget.Room(roomId))
}
override fun onResultClicked(roomDescription: RoomDescription) {
backstack.push(NavTarget.Room(roomDescription.roomId, roomDescription))
} }
}) })
.build() .build()

View file

@ -37,6 +37,7 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.room.joined.JoinedRoomFlowNode import io.element.android.appnav.room.joined.JoinedRoomFlowNode
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.features.joinroom.api.JoinRoomEntryPoint import io.element.android.features.joinroom.api.JoinRoomEntryPoint
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
@ -53,6 +54,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
import java.util.Optional
import kotlin.jvm.optionals.getOrNull import kotlin.jvm.optionals.getOrNull
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@ -72,6 +74,7 @@ class RoomFlowNode @AssistedInject constructor(
) { ) {
data class Inputs( data class Inputs(
val roomId: RoomId, val roomId: RoomId,
val roomDescription: Optional<RoomDescription>,
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages, val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages,
) : NodeInputs ) : NodeInputs
@ -102,6 +105,7 @@ class RoomFlowNode @AssistedInject constructor(
} }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
// When leaving the room from this session only, navigate up.
roomMembershipObserver.updates roomMembershipObserver.updates
.filter { update -> update.roomId == inputs.roomId && !update.isUserInRoom } .filter { update -> update.roomId == inputs.roomId && !update.isUserInRoom }
.onEach { .onEach {
@ -114,7 +118,7 @@ class RoomFlowNode @AssistedInject constructor(
return when (navTarget) { return when (navTarget) {
NavTarget.Loading -> loadingNode(buildContext) NavTarget.Loading -> loadingNode(buildContext)
NavTarget.JoinRoom -> { NavTarget.JoinRoom -> {
val inputs = JoinRoomEntryPoint.Inputs(inputs.roomId) val inputs = JoinRoomEntryPoint.Inputs(inputs.roomId, roomDescription = inputs.roomDescription)
joinRoomEntryPoint.createNode(this, buildContext, inputs) joinRoomEntryPoint.createNode(this, buildContext, inputs)
} }
NavTarget.JoinedRoom -> { NavTarget.JoinedRoom -> {

View file

@ -44,7 +44,6 @@ import io.element.android.libraries.di.SessionScope
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.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -60,10 +59,9 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
private val appNavigationStateService: AppNavigationStateService, private val appNavigationStateService: AppNavigationStateService,
private val appCoroutineScope: CoroutineScope, private val appCoroutineScope: CoroutineScope,
roomComponentFactory: RoomComponentFactory, roomComponentFactory: RoomComponentFactory,
roomMembershipObserver: RoomMembershipObserver,
) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>( ) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = when(plugins.filterIsInstance(Inputs::class.java).first().initialElement){ initialElement = when (plugins.filterIsInstance(Inputs::class.java).first().initialElement) {
RoomNavigationTarget.Messages -> NavTarget.Messages RoomNavigationTarget.Messages -> NavTarget.Messages
RoomNavigationTarget.Details -> NavTarget.RoomDetails RoomNavigationTarget.Details -> NavTarget.RoomDetails
RoomNavigationTarget.NotificationSettings -> NavTarget.RoomNotificationSettings RoomNavigationTarget.NotificationSettings -> NavTarget.RoomNotificationSettings

View file

@ -25,4 +25,5 @@ android {
dependencies { dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.features.roomdirectory.api)
} }

View file

@ -18,9 +18,11 @@ package io.element.android.features.joinroom.api
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import java.util.Optional
interface JoinRoomEntryPoint : FeatureEntryPoint { interface JoinRoomEntryPoint : FeatureEntryPoint {
@ -28,6 +30,7 @@ interface JoinRoomEntryPoint : FeatureEntryPoint {
data class Inputs( data class Inputs(
val roomId: RoomId, val roomId: RoomId,
val roomDescription: Optional<RoomDescription>,
) : NodeInputs ) : NodeInputs
} }

View file

@ -40,6 +40,7 @@ dependencies {
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.features.invite.api) implementation(projects.features.invite.api)
implementation(projects.features.roomdirectory.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)

View file

@ -38,7 +38,7 @@ class JoinRoomNode @AssistedInject constructor(
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private val inputs: JoinRoomEntryPoint.Inputs = inputs() private val inputs: JoinRoomEntryPoint.Inputs = inputs()
private val presenter = presenterFactory.create(inputs.roomId) private val presenter = presenterFactory.create(inputs.roomId, inputs.roomDescription)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {

View file

@ -25,6 +25,7 @@ import dagger.assisted.AssistedInject
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInvitePresenter import io.element.android.features.invite.api.response.AcceptDeclineInvitePresenter
import io.element.android.features.invite.api.response.InviteData import io.element.android.features.invite.api.response.InviteData
import io.element.android.features.roomdirectory.api.RoomDescription
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
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
@ -36,34 +37,29 @@ import kotlin.jvm.optionals.getOrNull
class JoinRoomPresenter @AssistedInject constructor( class JoinRoomPresenter @AssistedInject constructor(
@Assisted private val roomId: RoomId, @Assisted private val roomId: RoomId,
@Assisted private val roomDescription: Optional<RoomDescription>,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val acceptDeclineInvitePresenter: AcceptDeclineInvitePresenter, private val acceptDeclineInvitePresenter: AcceptDeclineInvitePresenter,
) : Presenter<JoinRoomState> { ) : Presenter<JoinRoomState> {
interface Factory { interface Factory {
fun create(roomId: RoomId): JoinRoomPresenter fun create(roomId: RoomId, roomDescription: Optional<RoomDescription>): JoinRoomPresenter
} }
@Composable @Composable
override fun present(): JoinRoomState { override fun present(): JoinRoomState {
val mxRoomInfo by matrixClient.getRoomInfoFlow(roomId).collectAsState(initial = Optional.empty()) val roomInfo by matrixClient.getRoomInfoFlow(roomId).collectAsState(initial = Optional.empty())
val joinAuthorisationStatus = joinAuthorisationStatus(mxRoomInfo) val joinAuthorisationStatus = joinAuthorisationStatus(roomInfo)
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present() val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
val roomInfo by produceState<AsyncData<RoomInfo>>(initialValue = AsyncData.Uninitialized, key1 = mxRoomInfo) { val contentState by produceState<AsyncData<ContentState>>(initialValue = AsyncData.Uninitialized, key1 = roomInfo) {
value = when { value = when {
mxRoomInfo.isPresent -> { roomInfo.isPresent -> {
val roomInfo = mxRoomInfo.get().let { val contentState = roomInfo.get().toContentState()
RoomInfo( AsyncData.Success(contentState)
roomId = roomId, }
roomName = it.name, roomDescription.isPresent -> {
roomAlias = it.canonicalAlias, val contentState = roomDescription.get().toContentState()
memberCount = it.activeMembersCount, AsyncData.Success(contentState)
isDirect = it.isDirect,
topic = it.topic,
roomAvatarUrl = it.avatarUrl
)
}
AsyncData.Success(roomInfo)
} }
else -> AsyncData.Uninitialized else -> AsyncData.Uninitialized
} }
@ -73,30 +69,68 @@ class JoinRoomPresenter @AssistedInject constructor(
when (event) { when (event) {
JoinRoomEvents.AcceptInvite, JoinRoomEvents.JoinRoom -> { JoinRoomEvents.AcceptInvite, JoinRoomEvents.JoinRoom -> {
acceptDeclineInviteState.eventSink( acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.AcceptInvite(roomInfo.toInviteData()) AcceptDeclineInviteEvents.AcceptInvite(contentState.toInviteData())
) )
} }
JoinRoomEvents.DeclineInvite -> { JoinRoomEvents.DeclineInvite -> {
acceptDeclineInviteState.eventSink( acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.DeclineInvite(roomInfo.toInviteData()) AcceptDeclineInviteEvents.DeclineInvite(contentState.toInviteData())
) )
} }
} }
} }
return JoinRoomState( return JoinRoomState(
roomInfo = roomInfo, contentState = contentState,
joinAuthorisationStatus = joinAuthorisationStatus, joinAuthorisationStatus = joinAuthorisationStatus,
acceptDeclineInviteState = acceptDeclineInviteState, acceptDeclineInviteState = acceptDeclineInviteState,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
private fun AsyncData<RoomInfo>.toInviteData(): InviteData { private fun RoomDescription.toContentState(): ContentState {
return ContentState(
roomId = roomId,
name = name,
description = description,
numberOfMembers = numberOfMembers,
isDirect = false,
roomAvatarUrl = avatarUrl
)
}
private fun MatrixRoomInfo.toContentState(): ContentState {
fun title(): String {
return name ?: canonicalAlias ?: roomId.value
}
fun description(): String? {
val topic = topic
val alias = canonicalAlias
val name = name
return when {
topic != null -> topic
name != null && alias != null -> alias
name == null && alias == null -> null
else -> roomId.value
}
}
return ContentState(
roomId = roomId,
name = title(),
description = description(),
numberOfMembers = activeMembersCount,
isDirect = isDirect,
roomAvatarUrl = avatarUrl
)
}
private fun AsyncData<ContentState>.toInviteData(): InviteData {
return dataOrNull().let { return dataOrNull().let {
InviteData( InviteData(
roomId = roomId, roomId = roomId,
roomName = it?.roomName ?: "", roomName = it?.name ?: "",
isDirect = it?.isDirect ?: false isDirect = it?.isDirect ?: false
) )
} }

View file

@ -25,27 +25,27 @@ import io.element.android.libraries.matrix.api.core.RoomId
@Immutable @Immutable
data class JoinRoomState( data class JoinRoomState(
val roomInfo: AsyncData<RoomInfo>, val contentState: AsyncData<ContentState>,
val joinAuthorisationStatus: JoinAuthorisationStatus, val joinAuthorisationStatus: JoinAuthorisationStatus,
val acceptDeclineInviteState: AcceptDeclineInviteState, val acceptDeclineInviteState: AcceptDeclineInviteState,
val eventSink: (JoinRoomEvents) -> Unit val eventSink: (JoinRoomEvents) -> Unit
) { )
val showMemberCount = roomInfo.dataOrNull()?.memberCount != null
}
data class RoomInfo( data class ContentState(
val roomId: RoomId, val roomId: RoomId,
val roomName: String?, val name: String,
val roomAlias: String?, val description: String?,
val memberCount: Long?, val numberOfMembers: Long?,
val topic: String?,
val isDirect: Boolean, val isDirect: Boolean,
val roomAvatarUrl: String?, val roomAvatarUrl: String?,
) { ) {
val showMemberCount = numberOfMembers != null
fun avatarData(size: AvatarSize): AvatarData { fun avatarData(size: AvatarSize): AvatarData {
return AvatarData( return AvatarData(
id = roomId.value, id = roomId.value,
name = roomName, name = name,
url = roomAvatarUrl, url = roomAvatarUrl,
size = size, size = size,
) )

View file

@ -26,7 +26,7 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
override val values: Sequence<JoinRoomState> override val values: Sequence<JoinRoomState>
get() = sequenceOf( get() = sequenceOf(
aJoinRoomState( aJoinRoomState(
roomInfo = AsyncData.Uninitialized contentState = AsyncData.Uninitialized
), ),
aJoinRoomState( aJoinRoomState(
joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin
@ -41,14 +41,13 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
} }
fun aJoinRoomState( fun aJoinRoomState(
roomInfo: AsyncData<RoomInfo> = AsyncData.Success( contentState: AsyncData<ContentState> = AsyncData.Success(
RoomInfo( ContentState(
roomId = RoomId("@exa:matrix.org"), roomId = RoomId("@exa:matrix.org"),
roomName = "Element x android", name = "Element x android",
roomAlias = "#exa:matrix.org", description = "#exa:matrix.org",
memberCount = null, numberOfMembers = null,
isDirect = false, isDirect = false,
topic = null,
roomAvatarUrl = null roomAvatarUrl = null
) )
), ),
@ -56,7 +55,7 @@ fun aJoinRoomState(
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(), acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
eventSink: (JoinRoomEvents) -> Unit = {} eventSink: (JoinRoomEvents) -> Unit = {}
) = JoinRoomState( ) = JoinRoomState(
roomInfo = roomInfo, contentState = contentState,
joinAuthorisationStatus = joinAuthorisationStatus, joinAuthorisationStatus = joinAuthorisationStatus,
acceptDeclineInviteState = acceptDeclineInviteState, acceptDeclineInviteState = acceptDeclineInviteState,
eventSink = eventSink eventSink = eventSink

View file

@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -33,7 +32,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewLightDark
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
@ -42,10 +40,8 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.avatar.Avatar 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.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -66,10 +62,10 @@ fun JoinRoomView(
HeaderFooterPage( HeaderFooterPage(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
JoinRoomTopBar(asyncRoomInfo = state.roomInfo, onBackClicked = onBackPressed) JoinRoomTopBar(onBackClicked = onBackPressed)
}, },
content = { content = {
JoinRoomContent(state = state) JoinRoomContent(asyncContentState = state.contentState)
}, },
footer = { footer = {
JoinRoomFooter( JoinRoomFooter(
@ -127,40 +123,65 @@ private fun JoinRoomFooter(
@Composable @Composable
private fun JoinRoomContent( private fun JoinRoomContent(
state: JoinRoomState, asyncContentState: AsyncData<ContentState>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(
modifier = modifier @Composable
.fillMaxWidth() fun ContentScaffold(
.padding(all = 16.dp), avatar: @Composable () -> Unit,
horizontalAlignment = Alignment.CenterHorizontally title: String,
description: String,
memberCount: @Composable (() -> Unit)? = null
) { ) {
when (state.roomInfo) { avatar()
is AsyncData.Success -> {
val roomInfo = state.roomInfo.data
Avatar(avatarData = roomInfo.avatarData(AvatarSize.RoomHeader))
}
else -> {
PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
}
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = stringResource(id = R.string.screen_join_room_title_no_preview), text = title,
style = ElementTheme.typography.fontHeadingMdBold, style = ElementTheme.typography.fontHeadingMdBold,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = stringResource(id = R.string.screen_join_room_subtitle_no_preview), text = description,
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
) )
if (state.showMemberCount) { memberCount?.invoke()
JoinRoomMembersCount(memberCount = state.roomInfo.dataOrNull()?.memberCount ?: 0) }
Column(
modifier = modifier
.fillMaxWidth()
.padding(all = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
when (asyncContentState) {
is AsyncData.Success -> {
val contentState = asyncContentState.data
ContentScaffold(
avatar = {
Avatar(contentState.avatarData(AvatarSize.RoomHeader))
},
title = contentState.name,
description = contentState.description ?: stringResource(R.string.screen_join_room_subtitle_no_preview)
) {
if (contentState.showMemberCount) {
JoinRoomMembersCount(memberCount = contentState.numberOfMembers ?: 0)
}
}
}
else -> {
ContentScaffold(
avatar = {
PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
},
title = stringResource(R.string.screen_join_room_title_no_preview),
description = stringResource(R.string.screen_join_room_subtitle_no_preview),
)
}
} }
} }
} }
@ -192,7 +213,6 @@ fun JoinRoomMembersCount(memberCount: Long) {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun JoinRoomTopBar( private fun JoinRoomTopBar(
asyncRoomInfo: AsyncData<RoomInfo>,
onBackClicked: () -> Unit, onBackClicked: () -> Unit,
) { ) {
TopAppBar( TopAppBar(
@ -200,44 +220,11 @@ private fun JoinRoomTopBar(
BackButton(onClick = onBackClicked) BackButton(onClick = onBackClicked)
}, },
title = { title = {
when (asyncRoomInfo) {
is AsyncData.Success -> {
val roomInfo = asyncRoomInfo.data
if(roomInfo.roomName == null){
IconTitlePlaceholdersRowMolecule(iconSize = AvatarSize.TimelineRoom.dp)
}else {
RoomAvatarAndNameRow(roomName = roomInfo.roomName, roomAvatar = roomInfo.avatarData(AvatarSize.TimelineRoom))
}
}
else -> {
IconTitlePlaceholdersRowMolecule(iconSize = AvatarSize.TimelineRoom.dp)
}
}
}, },
) )
} }
@Composable
private fun RoomAvatarAndNameRow(
roomName: String,
roomAvatar: AvatarData,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Avatar(roomAvatar)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = roomName,
style = ElementTheme.typography.fontBodyLgMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@PreviewLightDark @PreviewLightDark
@Composable @Composable
fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class) state: JoinRoomState) = ElementPreview { fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class) state: JoinRoomState) = ElementPreview {

View file

@ -21,10 +21,12 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import io.element.android.features.invite.api.response.AcceptDeclineInvitePresenter import io.element.android.features.invite.api.response.AcceptDeclineInvitePresenter
import io.element.android.features.joinroom.impl.JoinRoomPresenter import io.element.android.features.joinroom.impl.JoinRoomPresenter
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
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.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import java.util.Optional
@Module @Module
@ContributesTo(SessionScope::class) @ContributesTo(SessionScope::class)
@ -36,9 +38,10 @@ object JoinRoomModule {
acceptDeclineInvitePresenter: AcceptDeclineInvitePresenter, acceptDeclineInvitePresenter: AcceptDeclineInvitePresenter,
): JoinRoomPresenter.Factory { ): JoinRoomPresenter.Factory {
return object : JoinRoomPresenter.Factory { return object : JoinRoomPresenter.Factory {
override fun create(roomId: RoomId): JoinRoomPresenter { override fun create(roomId: RoomId, roomDescription: Optional<RoomDescription>): JoinRoomPresenter {
return JoinRoomPresenter( return JoinRoomPresenter(
roomId = roomId, roomId = roomId,
roomDescription = roomDescription,
matrixClient = client, matrixClient = client,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
) )

View file

@ -16,6 +16,7 @@
plugins { plugins {
id("io.element.android-library") id("io.element.android-library")
id("kotlin-parcelize")
} }
android { android {

View file

@ -16,13 +16,26 @@
package io.element.android.features.roomdirectory.api package io.element.android.features.roomdirectory.api
import android.os.Parcelable
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.parcelize.Parcelize
@Parcelize
data class RoomDescription( data class RoomDescription(
val roomId: RoomId, val roomId: RoomId,
val name: String, val name: String,
val description: String, val description: String,
val avatarData: AvatarData, val avatarUrl: String?,
val canBeJoined: Boolean, val canBeJoined: Boolean,
) val numberOfMembers: Long,
) : Parcelable {
fun avatarData(size: AvatarSize) = AvatarData(
id = roomId.value,
name = name,
url = avatarUrl,
size = size,
)
}

View file

@ -31,6 +31,7 @@ interface RoomDirectoryEntryPoint : FeatureEntryPoint {
} }
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenRoom(roomId: RoomId) fun onRoomJoined(roomId: RoomId)
fun onResultClicked(roomDescription: RoomDescription)
} }
} }

View file

@ -25,6 +25,7 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -35,9 +36,16 @@ class RoomDirectoryNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: RoomDirectoryPresenter, private val presenter: RoomDirectoryPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private fun onResultClicked(roomDescription: RoomDescription) {
plugins<RoomDirectoryEntryPoint.Callback>().forEach {
it.onResultClicked(roomDescription)
}
}
private fun onRoomJoined(roomId: RoomId) { private fun onRoomJoined(roomId: RoomId) {
plugins<RoomDirectoryEntryPoint.Callback>().forEach { plugins<RoomDirectoryEntryPoint.Callback>().forEach {
it.onOpenRoom(roomId) it.onRoomJoined(roomId)
} }
} }
@ -47,6 +55,7 @@ class RoomDirectoryNode @AssistedInject constructor(
RoomDirectoryView( RoomDirectoryView(
state = state, state = state,
onRoomJoined = ::onRoomJoined, onRoomJoined = ::onRoomJoined,
onResultClicked = ::onResultClicked,
onBackPressed = ::navigateUp, onBackPressed = ::navigateUp,
modifier = modifier modifier = modifier
) )

View file

@ -19,8 +19,6 @@ package io.element.android.features.roomdirectory.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -71,25 +69,17 @@ fun aRoomDescriptionList(): ImmutableList<RoomDescription> {
roomId = RoomId("!exa:matrix.org"), roomId = RoomId("!exa:matrix.org"),
name = "Element X Android", name = "Element X Android",
description = "Element X is a secure, private and decentralized messenger.", description = "Element X is a secure, private and decentralized messenger.",
avatarData = AvatarData( avatarUrl = null,
id = "!exa:matrix.org",
name = "Element X Android",
url = null,
size = AvatarSize.RoomDirectoryItem
),
canBeJoined = true, canBeJoined = true,
numberOfMembers = 2765,
), ),
RoomDescription( RoomDescription(
roomId = RoomId("!exi:matrix.org"), roomId = RoomId("!exi:matrix.org"),
name = "Element X iOS", name = "Element X iOS",
description = "Element X is a secure, private and decentralized messenger.", description = "Element X is a secure, private and decentralized messenger.",
avatarData = AvatarData( avatarUrl = null,
id = "!exi:matrix.org",
name = "Element X iOS",
url = null,
size = AvatarSize.RoomDirectoryItem
),
canBeJoined = false, canBeJoined = false,
numberOfMembers = 356,
) )
) )
} }

View file

@ -51,6 +51,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.roomdirectory.impl.R import io.element.android.features.roomdirectory.impl.R
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
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.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -70,12 +71,13 @@ import kotlinx.collections.immutable.ImmutableList
@Composable @Composable
fun RoomDirectoryView( fun RoomDirectoryView(
state: RoomDirectoryState, state: RoomDirectoryState,
onResultClicked: (RoomDescription) -> Unit,
onRoomJoined: (RoomId) -> Unit, onRoomJoined: (RoomId) -> Unit,
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun joinRoom(roomId: RoomId) { fun joinRoom(roomDescription: RoomDescription) {
state.eventSink(RoomDirectoryEvents.JoinRoom(roomId)) state.eventSink(RoomDirectoryEvents.JoinRoom(roomDescription.roomId))
} }
Scaffold( Scaffold(
@ -86,10 +88,11 @@ fun RoomDirectoryView(
content = { padding -> content = { padding ->
RoomDirectoryContent( RoomDirectoryContent(
state = state, state = state,
onResultClicked = ::joinRoom, onResultClicked = onResultClicked,
onJoinClicked = ::joinRoom,
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
) )
} }
) )
@ -128,7 +131,8 @@ private fun RoomDirectoryTopBar(
@Composable @Composable
private fun RoomDirectoryContent( private fun RoomDirectoryContent(
state: RoomDirectoryState, state: RoomDirectoryState,
onResultClicked: (RoomId) -> Unit, onResultClicked: (RoomDescription) -> Unit,
onJoinClicked: (RoomDescription) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier) { Column(modifier = modifier) {
@ -143,6 +147,7 @@ private fun RoomDirectoryContent(
displayLoadMoreIndicator = state.displayLoadMoreIndicator, displayLoadMoreIndicator = state.displayLoadMoreIndicator,
displayEmptyState = state.displayEmptyState, displayEmptyState = state.displayEmptyState,
onResultClicked = onResultClicked, onResultClicked = onResultClicked,
onJoinClicked = onJoinClicked,
onReachedLoadMore = { state.eventSink(RoomDirectoryEvents.LoadMore) }, onReachedLoadMore = { state.eventSink(RoomDirectoryEvents.LoadMore) },
) )
} }
@ -153,7 +158,8 @@ private fun RoomDirectoryRoomList(
roomDescriptions: ImmutableList<RoomDescription>, roomDescriptions: ImmutableList<RoomDescription>,
displayLoadMoreIndicator: Boolean, displayLoadMoreIndicator: Boolean,
displayEmptyState: Boolean, displayEmptyState: Boolean,
onResultClicked: (RoomId) -> Unit, onResultClicked: (RoomDescription) -> Unit,
onJoinClicked: (RoomDescription) -> Unit,
onReachedLoadMore: () -> Unit, onReachedLoadMore: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -161,7 +167,12 @@ private fun RoomDirectoryRoomList(
items(roomDescriptions) { roomDescription -> items(roomDescriptions) { roomDescription ->
RoomDirectoryRoomRow( RoomDirectoryRoomRow(
roomDescription = roomDescription, roomDescription = roomDescription,
onClick = onResultClicked, onClick = {
onResultClicked(roomDescription)
},
onJoinClick = {
onJoinClicked(roomDescription)
},
) )
} }
if (displayEmptyState) { if (displayEmptyState) {
@ -188,10 +199,10 @@ private fun RoomDirectoryRoomList(
@Composable @Composable
private fun LoadMoreIndicator(modifier: Modifier = Modifier) { private fun LoadMoreIndicator(modifier: Modifier = Modifier) {
Box( Box(
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight() .wrapContentHeight()
.padding(24.dp), .padding(24.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
CircularProgressIndicator( CircularProgressIndicator(
@ -256,30 +267,29 @@ private fun SearchTextField(
@Composable @Composable
private fun RoomDirectoryRoomRow( private fun RoomDirectoryRoomRow(
roomDescription: RoomDescription, roomDescription: RoomDescription,
onClick: (RoomId) -> Unit, onClick: () -> Unit,
onJoinClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(enabled = roomDescription.canBeJoined) { .clickable(enabled = roomDescription.canBeJoined, onClick = onClick)
onClick(roomDescription.roomId) .padding(
} top = 12.dp,
.padding( bottom = 12.dp,
top = 12.dp, start = 16.dp,
bottom = 12.dp, )
start = 16.dp, .height(IntrinsicSize.Min),
)
.height(IntrinsicSize.Min),
) { ) {
Avatar( Avatar(
avatarData = roomDescription.avatarData, avatarData = roomDescription.avatarData(AvatarSize.RoomDirectoryItem),
modifier = Modifier.align(Alignment.CenterVertically) modifier = Modifier.align(Alignment.CenterVertically)
) )
Column( Column(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(start = 16.dp) .padding(start = 16.dp)
) { ) {
Text( Text(
text = roomDescription.name, text = roomDescription.name,
@ -301,8 +311,9 @@ private fun RoomDirectoryRoomRow(
text = stringResource(id = CommonStrings.action_join), text = stringResource(id = CommonStrings.action_join),
color = ElementTheme.colors.textSuccessPrimary, color = ElementTheme.colors.textSuccessPrimary,
modifier = Modifier modifier = Modifier
.align(Alignment.CenterVertically) .align(Alignment.CenterVertically)
.padding(start = 4.dp, end = 12.dp) .clickable(onClick = onJoinClick)
.padding(start = 4.dp, end = 12.dp)
) )
} else { } else {
Spacer(modifier = Modifier.width(24.dp)) Spacer(modifier = Modifier.width(24.dp))
@ -315,6 +326,7 @@ private fun RoomDirectoryRoomRow(
internal fun RoomDirectoryViewPreview(@PreviewParameter(RoomDirectoryStateProvider::class) state: RoomDirectoryState) = ElementPreview { internal fun RoomDirectoryViewPreview(@PreviewParameter(RoomDirectoryStateProvider::class) state: RoomDirectoryState) = ElementPreview {
RoomDirectoryView( RoomDirectoryView(
state = state, state = state,
onResultClicked = {},
onRoomJoined = {}, onRoomJoined = {},
onBackPressed = {}, onBackPressed = {},
) )

View file

@ -42,12 +42,8 @@ fun MatrixRoomDescription.toFeatureModel(): RoomDescription {
roomId = roomId, roomId = roomId,
name = name(), name = name(),
description = description(), description = description(),
avatarData = AvatarData( avatarUrl = avatarUrl,
id = roomId.value, numberOfMembers = numberOfMembers,
name = name,
url = avatarUrl,
size = AvatarSize.RoomDirectoryItem,
),
canBeJoined = joinRule == MatrixRoomDescription.JoinRule.PUBLIC, canBeJoined = joinRule == MatrixRoomDescription.JoinRule.PUBLIC,
) )
} }

View file

@ -105,7 +105,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
setContent { setContent {
RoomDirectoryView( RoomDirectoryView(
state = state, state = state,
onRoomJoined = onRoomJoined, onRoomClicked = onRoomJoined,
onBackPressed = onBackPressed, onBackPressed = onBackPressed,
) )
} }