Merge pull request #794 from vector-im/feature/fga/waiting_ss_room
Feature/fga/waiting ss room
This commit is contained in:
commit
f32a0ca1ad
34 changed files with 592 additions and 145 deletions
|
|
@ -28,7 +28,7 @@ import com.bumble.appyx.core.node.Node
|
|||
import com.bumble.appyx.core.node.ParentNode
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.appnav.LoggedInFlowNode
|
||||
import io.element.android.appnav.RoomFlowNode
|
||||
import io.element.android.appnav.room.RoomLoadedFlowNode
|
||||
import io.element.android.appnav.RootFlowNode
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
|
|
@ -67,7 +67,7 @@ class MainNode(
|
|||
}
|
||||
}
|
||||
|
||||
private val roomFlowNodeCallback = object : RoomFlowNode.LifecycleCallback {
|
||||
private val roomFlowNodeCallback = object : RoomLoadedFlowNode.LifecycleCallback {
|
||||
override fun onFlowCreated(identifier: String, room: MatrixRoom) {
|
||||
val component = bindings<RoomComponent.ParentBindings>().roomComponentBuilder().room(room).build()
|
||||
mainDaggerComponentOwner.addComponent(identifier, component)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ import dagger.assisted.Assisted
|
|||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.appnav.loggedin.LoggedInNode
|
||||
import io.element.android.appnav.room.RoomFlowNode
|
||||
import io.element.android.appnav.room.RoomLoadedFlowNode
|
||||
import io.element.android.features.analytics.api.AnalyticsEntryPoint
|
||||
import io.element.android.features.createroom.api.CreateRoomEntryPoint
|
||||
import io.element.android.features.invitelist.api.InviteListEntryPoint
|
||||
|
|
@ -56,7 +58,6 @@ import io.element.android.libraries.architecture.animation.rememberDefaultTransi
|
|||
import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
|
|
@ -138,6 +139,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
observeAnalyticsState()
|
||||
lifecycle.subscribe(
|
||||
onCreate = {
|
||||
syncService.startSync()
|
||||
plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.matrixClient) }
|
||||
val imageLoaderFactory = bindings<MatrixUIBindings>().loggedInImageLoaderFactory()
|
||||
Coil.setImageLoader(imageLoaderFactory)
|
||||
|
|
@ -194,7 +196,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
@Parcelize
|
||||
data class Room(
|
||||
val roomId: RoomId,
|
||||
val initialElement: RoomFlowNode.NavTarget = RoomFlowNode.NavTarget.Messages
|
||||
val initialElement: RoomLoadedFlowNode.NavTarget = RoomLoadedFlowNode.NavTarget.Messages
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
|
|
@ -241,7 +243,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
}
|
||||
|
||||
override fun onRoomSettingsClicked(roomId: RoomId) {
|
||||
backstack.push(NavTarget.Room(roomId, initialElement = RoomFlowNode.NavTarget.RoomDetails))
|
||||
backstack.push(NavTarget.Room(roomId, initialElement = RoomLoadedFlowNode.NavTarget.RoomDetails))
|
||||
}
|
||||
|
||||
override fun onReportBugClicked() {
|
||||
|
|
@ -254,24 +256,14 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
.build()
|
||||
}
|
||||
is NavTarget.Room -> {
|
||||
val room = inputs.matrixClient.getRoom(roomId = navTarget.roomId)
|
||||
if (room == null) {
|
||||
// TODO CREATE UNKNOWN ROOM NODE
|
||||
node(buildContext) {
|
||||
Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = "Unknown room with id = ${navTarget.roomId}")
|
||||
}
|
||||
val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
|
||||
val callback = object : RoomLoadedFlowNode.Callback {
|
||||
override fun onForwardedToSingleRoom(roomId: RoomId) {
|
||||
coroutineScope.launch { attachRoom(roomId) }
|
||||
}
|
||||
} else {
|
||||
val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
|
||||
val callback = object : RoomFlowNode.Callback {
|
||||
override fun onForwardedToSingleRoom(roomId: RoomId) {
|
||||
coroutineScope.launch { attachRoom(roomId) }
|
||||
}
|
||||
}
|
||||
val inputs = RoomFlowNode.Inputs(room, initialElement = navTarget.initialElement)
|
||||
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks)
|
||||
}
|
||||
val inputs = RoomFlowNode.Inputs(roomId = navTarget.roomId, initialElement = navTarget.initialElement)
|
||||
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks)
|
||||
}
|
||||
NavTarget.Settings -> {
|
||||
val callback = object : PreferencesEntryPoint.Callback {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
/*
|
||||
* 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.appnav.room
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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 androidx.compose.ui.unit.sp
|
||||
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
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.designsystem.theme.placeholderBackground
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun LoadingRoomNodeView(
|
||||
state: LoadingRoomState,
|
||||
hasNetworkConnection: Boolean,
|
||||
onBackClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
contentWindowInsets = WindowInsets.systemBars,
|
||||
topBar = {
|
||||
Column {
|
||||
ConnectivityIndicatorView(isOnline = hasNetworkConnection)
|
||||
LoadingRoomTopBar(onBackClicked)
|
||||
}
|
||||
},
|
||||
content = { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp), contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (state is LoadingRoomState.Error) {
|
||||
Text(
|
||||
text = stringResource(id = CommonStrings.error_unknown),
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LoadingRoomTopBar(onBackClicked: () -> Unit) {
|
||||
TopAppBar(
|
||||
modifier = Modifier,
|
||||
navigationIcon = {
|
||||
BackButton(onClick = onBackClicked)
|
||||
},
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(AvatarSize.TimelineRoom.dp)
|
||||
.align(Alignment.CenterVertically)
|
||||
.background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
PlaceholderAtom(width = 20.dp, height = 7.dp)
|
||||
Spacer(modifier = Modifier.width(7.dp))
|
||||
PlaceholderAtom(width = 45.dp, height = 7.dp)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun LoadingRoomNodeViewLightPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) =
|
||||
ElementPreviewLight { ContentToPreview(state) }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun LoadingRoomNodeViewDarkPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) =
|
||||
ElementPreviewDark { ContentToPreview(state) }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview(state: LoadingRoomState) {
|
||||
LoadingRoomNodeView(
|
||||
state = state,
|
||||
onBackClicked = {},
|
||||
hasNetworkConnection = false
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* 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.appnav.room
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed interface LoadingRoomState {
|
||||
object Loading : LoadingRoomState
|
||||
object Error : LoadingRoomState
|
||||
data class Loaded(val room: MatrixRoom) : LoadingRoomState
|
||||
}
|
||||
|
||||
open class LoadingRoomStateProvider : PreviewParameterProvider<LoadingRoomState> {
|
||||
override val values: Sequence<LoadingRoomState>
|
||||
get() = sequenceOf(
|
||||
LoadingRoomState.Loading,
|
||||
LoadingRoomState.Error
|
||||
)
|
||||
}
|
||||
|
||||
@SingleIn(SessionScope::class)
|
||||
class LoadingRoomStateFlowFactory @Inject constructor(private val matrixClient: MatrixClient) {
|
||||
|
||||
fun create(lifecycleScope: CoroutineScope, roomId: RoomId): StateFlow<LoadingRoomState> =
|
||||
getRoomFlow(roomId)
|
||||
.map { room ->
|
||||
if (room != null) {
|
||||
LoadingRoomState.Loaded(room)
|
||||
} else {
|
||||
LoadingRoomState.Error
|
||||
}
|
||||
}
|
||||
.stateIn(lifecycleScope, SharingStarted.Eagerly, LoadingRoomState.Loading)
|
||||
|
||||
private fun getRoomFlow(roomId: RoomId): Flow<MatrixRoom?> = suspend {
|
||||
matrixClient.getRoom(roomId = roomId)
|
||||
}
|
||||
.asFlow()
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package io.element.android.appnav.room
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumble.appyx.core.composable.Children
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
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 dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.appnav.NodeLifecycleCallback
|
||||
import io.element.android.appnav.safeRoot
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class RoomFlowNode @AssistedInject constructor(
|
||||
@Assisted val buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
loadingRoomStateFlowFactory: LoadingRoomStateFlowFactory,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
) :
|
||||
BackstackNode<RoomFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Loading,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins
|
||||
) {
|
||||
|
||||
data class Inputs(
|
||||
val roomId: RoomId,
|
||||
val initialElement: RoomLoadedFlowNode.NavTarget = RoomLoadedFlowNode.NavTarget.Messages,
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs: Inputs = inputs()
|
||||
private val loadingRoomStateStateFlow = loadingRoomStateFlowFactory.create(lifecycleScope, inputs.roomId)
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object Loading : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object Loaded : NavTarget
|
||||
}
|
||||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
loadingRoomStateStateFlow
|
||||
.map {
|
||||
it is LoadingRoomState.Loaded
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.onEach { isLoaded ->
|
||||
if (isLoaded) {
|
||||
backstack.safeRoot(NavTarget.Loaded)
|
||||
} else {
|
||||
backstack.safeRoot(NavTarget.Loading)
|
||||
}
|
||||
}.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Loaded -> {
|
||||
val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
|
||||
val roomFlowNodeCallback = plugins<RoomLoadedFlowNode.Callback>()
|
||||
val awaitRoomState = loadingRoomStateStateFlow.value
|
||||
if (awaitRoomState is LoadingRoomState.Loaded) {
|
||||
val inputs = RoomLoadedFlowNode.Inputs(awaitRoomState.room, initialElement = inputs.initialElement)
|
||||
createNode<RoomLoadedFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback + nodeLifecycleCallbacks)
|
||||
} else {
|
||||
loadingNode(buildContext, this::navigateUp)
|
||||
}
|
||||
}
|
||||
NavTarget.Loading -> {
|
||||
loadingNode(buildContext, this::navigateUp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadingNode(buildContext: BuildContext, onBackClicked: () -> Unit) = node(buildContext) { modifier ->
|
||||
val loadingRoomState by loadingRoomStateStateFlow.collectAsState()
|
||||
val networkStatus by networkMonitor.connectivity.collectAsState()
|
||||
LoadingRoomNodeView(
|
||||
state = loadingRoomState,
|
||||
hasNetworkConnection = networkStatus == NetworkStatus.Online,
|
||||
modifier = modifier,
|
||||
onBackClicked = onBackClicked
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Children(
|
||||
navModel = backstack,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.appnav
|
||||
package io.element.android.appnav.room
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -32,6 +32,7 @@ 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.appnav.NodeLifecycleCallback
|
||||
import io.element.android.features.messages.api.MessagesEntryPoint
|
||||
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
|
|
@ -52,14 +53,14 @@ import kotlinx.parcelize.Parcelize
|
|||
import timber.log.Timber
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class RoomFlowNode @AssistedInject constructor(
|
||||
class RoomLoadedFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val messagesEntryPoint: MessagesEntryPoint,
|
||||
private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
|
||||
private val appNavigationStateService: AppNavigationStateService,
|
||||
roomMembershipObserver: RoomMembershipObserver,
|
||||
) : BackstackNode<RoomFlowNode.NavTarget>(
|
||||
) : BackstackNode<RoomLoadedFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialElement,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
|
|
@ -26,6 +26,7 @@ import com.bumble.appyx.navmodel.backstack.activeElement
|
|||
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
|
||||
import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
|
||||
import com.google.common.truth.Truth
|
||||
import io.element.android.appnav.room.RoomLoadedFlowNode
|
||||
import io.element.android.features.messages.api.MessagesEntryPoint
|
||||
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
||||
import io.element.android.libraries.architecture.childNode
|
||||
|
|
@ -76,7 +77,7 @@ class RoomFlowNodeTest {
|
|||
plugins: List<Plugin>,
|
||||
messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(),
|
||||
roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(),
|
||||
) = RoomFlowNode(
|
||||
) = RoomLoadedFlowNode(
|
||||
buildContext = BuildContext.root(savedStateMap = null),
|
||||
plugins = plugins,
|
||||
messagesEntryPoint = messagesEntryPoint,
|
||||
|
|
@ -90,15 +91,15 @@ class RoomFlowNodeTest {
|
|||
// GIVEN
|
||||
val room = FakeMatrixRoom()
|
||||
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
|
||||
val inputs = RoomFlowNode.Inputs(room)
|
||||
val inputs = RoomLoadedFlowNode.Inputs(room)
|
||||
val roomFlowNode = aRoomFlowNode(listOf(inputs), fakeMessagesEntryPoint)
|
||||
// WHEN
|
||||
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
|
||||
|
||||
// THEN
|
||||
Truth.assertThat(roomFlowNode.backstack.activeElement).isEqualTo(RoomFlowNode.NavTarget.Messages)
|
||||
roomFlowNodeTestHelper.assertChildHasLifecycle(RoomFlowNode.NavTarget.Messages, Lifecycle.State.CREATED)
|
||||
val messagesNode = roomFlowNode.childNode(RoomFlowNode.NavTarget.Messages)!!
|
||||
Truth.assertThat(roomFlowNode.backstack.activeElement).isEqualTo(RoomLoadedFlowNode.NavTarget.Messages)
|
||||
roomFlowNodeTestHelper.assertChildHasLifecycle(RoomLoadedFlowNode.NavTarget.Messages, Lifecycle.State.CREATED)
|
||||
val messagesNode = roomFlowNode.childNode(RoomLoadedFlowNode.NavTarget.Messages)!!
|
||||
Truth.assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId)
|
||||
}
|
||||
|
||||
|
|
@ -108,14 +109,14 @@ class RoomFlowNodeTest {
|
|||
val room = FakeMatrixRoom()
|
||||
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
|
||||
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
|
||||
val inputs = RoomFlowNode.Inputs(room)
|
||||
val inputs = RoomLoadedFlowNode.Inputs(room)
|
||||
val roomFlowNode = aRoomFlowNode(listOf(inputs), fakeMessagesEntryPoint, fakeRoomDetailsEntryPoint)
|
||||
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
|
||||
// WHEN
|
||||
fakeMessagesEntryPoint.callback?.onRoomDetailsClicked()
|
||||
// THEN
|
||||
roomFlowNodeTestHelper.assertChildHasLifecycle(RoomFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED)
|
||||
val roomDetailsNode = roomFlowNode.childNode(RoomFlowNode.NavTarget.RoomDetails)!!
|
||||
roomFlowNodeTestHelper.assertChildHasLifecycle(RoomLoadedFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED)
|
||||
val roomDetailsNode = roomFlowNode.childNode(RoomLoadedFlowNode.NavTarget.RoomDetails)!!
|
||||
Truth.assertThat(roomDetailsNode.id).isEqualTo(fakeRoomDetailsEntryPoint.nodeId)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* 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.appnav.room
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth
|
||||
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class LoadingRoomStateFlowFactoryTest {
|
||||
|
||||
@Test
|
||||
fun `flow should emit Loading and then Loaded when there is a room in cache`() = runTest {
|
||||
val room = FakeMatrixRoom(sessionId= A_SESSION_ID, roomId = A_ROOM_ID)
|
||||
val matrixClient = FakeMatrixClient(A_SESSION_ID).apply {
|
||||
givenGetRoomResult(A_ROOM_ID, room)
|
||||
}
|
||||
val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
|
||||
flowFactory
|
||||
.create(this, A_ROOM_ID)
|
||||
.test {
|
||||
Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
|
||||
Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flow should emit Loading and then Loaded when there is a room in cache after SS is loaded`() = runTest {
|
||||
val room = FakeMatrixRoom(sessionId= A_SESSION_ID, roomId = A_ROOM_ID)
|
||||
val roomSummaryDataSource = FakeRoomSummaryDataSource()
|
||||
val matrixClient = FakeMatrixClient(A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource)
|
||||
val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
|
||||
flowFactory
|
||||
.create(this, A_ROOM_ID)
|
||||
.test {
|
||||
Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
|
||||
matrixClient.givenGetRoomResult(A_ROOM_ID, room)
|
||||
roomSummaryDataSource.postLoadingState(RoomSummaryDataSource.LoadingState.Loaded(1))
|
||||
Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flow should emit Loading and then Error when there is no room in cache after SS is loaded`() = runTest {
|
||||
val roomSummaryDataSource = FakeRoomSummaryDataSource()
|
||||
val matrixClient = FakeMatrixClient(A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource)
|
||||
val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
|
||||
flowFactory
|
||||
.create(this, A_ROOM_ID)
|
||||
.test {
|
||||
Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
|
||||
roomSummaryDataSource.postLoadingState(RoomSummaryDataSource.LoadingState.Loaded(1))
|
||||
Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -65,20 +65,9 @@ class CreateRoomRootPresenter @Inject constructor(
|
|||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val startDmAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
|
||||
|
||||
fun startDm(matrixUser: MatrixUser) {
|
||||
startDmAction.value = Async.Uninitialized
|
||||
matrixClient.findDM(matrixUser.userId).use { existingDM ->
|
||||
if (existingDM == null) {
|
||||
localCoroutineScope.createDM(matrixUser, startDmAction)
|
||||
} else {
|
||||
startDmAction.value = Async.Success(existingDM.roomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleEvents(event: CreateRoomRootEvents) {
|
||||
when (event) {
|
||||
is CreateRoomRootEvents.StartDM -> startDm(event.matrixUser)
|
||||
is CreateRoomRootEvents.StartDM -> localCoroutineScope.startDm(event.matrixUser, startDmAction)
|
||||
CreateRoomRootEvents.CancelStartDM -> startDmAction.value = Async.Uninitialized
|
||||
}
|
||||
}
|
||||
|
|
@ -91,10 +80,20 @@ class CreateRoomRootPresenter @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.createDM(user: MatrixUser, startDmAction: MutableState<Async<RoomId>>) = launch {
|
||||
private fun CoroutineScope.startDm(matrixUser: MatrixUser, startDmAction: MutableState<Async<RoomId>>) = launch {
|
||||
suspend {
|
||||
matrixClient.createDM(user.userId).getOrThrow()
|
||||
.also { analyticsService.capture(CreatedRoom(isDM = true)) }
|
||||
matrixClient.findDM(matrixUser.userId).use { existingDM ->
|
||||
existingDM?.roomId ?: createDM(matrixUser)
|
||||
}
|
||||
}.runCatchingUpdatingState(startDmAction)
|
||||
}
|
||||
|
||||
private suspend fun createDM(user: MatrixUser): RoomId {
|
||||
return matrixClient
|
||||
.createDM(user.userId)
|
||||
.onSuccess {
|
||||
analyticsService.capture(CreatedRoom(isDM = true))
|
||||
}
|
||||
.getOrThrow()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -176,7 +176,6 @@ class CreateRoomRootPresenterTests {
|
|||
// Retry with success
|
||||
fakeMatrixClient.givenCreateDmError(null)
|
||||
stateAfterSecondAttempt.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
||||
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Uninitialized::class.java)
|
||||
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
|
||||
val stateAfterRetryStartDM = awaitItem()
|
||||
assertThat(stateAfterRetryStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class LeaveRoomPresenterImpl @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun showLeaveRoomAlert(
|
||||
private suspend fun showLeaveRoomAlert(
|
||||
matrixClient: MatrixClient,
|
||||
roomId: RoomId,
|
||||
confirmation: MutableState<LeaveRoomState.Confirmation>,
|
||||
|
|
@ -105,7 +105,7 @@ private suspend fun MatrixClient.leaveRoom(
|
|||
room.leave().onSuccess {
|
||||
roomMembershipObserver.notifyUserLeftRoom(room.roomId)
|
||||
}.onFailure {
|
||||
Timber.e(it, "Error while leaving room ${room.name} - ${room.roomId}")
|
||||
Timber.e(it, "Error while leaving room ${room.displayName} - ${room.roomId}")
|
||||
error.value = LeaveRoomState.Error.Shown
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import androidx.compose.runtime.collectAsState
|
|||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
|
|
@ -107,13 +108,12 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
|
||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||
val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
||||
val roomName: MutableState<String?> = rememberSaveable {
|
||||
mutableStateOf(null)
|
||||
val roomName by produceState(initialValue = room.displayName, key1 = syncUpdateFlow.value){
|
||||
value = room.displayName
|
||||
}
|
||||
val roomAvatar: MutableState<AvatarData?> = remember {
|
||||
mutableStateOf(null)
|
||||
val roomAvatar by produceState(initialValue = room.avatarData(), key1 = syncUpdateFlow.value){
|
||||
value = room.avatarData()
|
||||
}
|
||||
|
||||
var hasDismissedInviteDialog by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
|
@ -134,16 +134,6 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
|
||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||
|
||||
LaunchedEffect(syncUpdateFlow.value) {
|
||||
roomAvatar.value =
|
||||
AvatarData(
|
||||
id = room.roomId.value,
|
||||
name = room.name,
|
||||
url = room.avatarUrl,
|
||||
size = AvatarSize.TimelineRoom
|
||||
)
|
||||
roomName.value = room.name
|
||||
}
|
||||
LaunchedEffect(composerState.mode.relatedEventId) {
|
||||
timelineState.eventSink(TimelineEvents.SetHighlightedEvent(composerState.mode.relatedEventId))
|
||||
}
|
||||
|
|
@ -169,8 +159,8 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
|
||||
return MessagesState(
|
||||
roomId = room.roomId,
|
||||
roomName = roomName.value,
|
||||
roomAvatar = roomAvatar.value,
|
||||
roomName = roomName,
|
||||
roomAvatar = roomAvatar,
|
||||
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
||||
composerState = composerState,
|
||||
timelineState = timelineState,
|
||||
|
|
@ -185,6 +175,15 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private fun MatrixRoom.avatarData(): AvatarData {
|
||||
return AvatarData(
|
||||
id = roomId.value,
|
||||
name = displayName,
|
||||
url = avatarUrl,
|
||||
size = AvatarSize.TimelineRoom
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.handleTimelineAction(
|
||||
action: TimelineItemAction,
|
||||
targetEvent: TimelineItem.Event,
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
|||
@Immutable
|
||||
data class MessagesState(
|
||||
val roomId: RoomId,
|
||||
val roomName: String?,
|
||||
val roomAvatar: AvatarData?,
|
||||
val roomName: String,
|
||||
val roomAvatar: AvatarData,
|
||||
val userHasPermissionToSendMessage: Boolean,
|
||||
val composerState: MessageComposerState,
|
||||
val timelineState: TimelineState,
|
||||
|
|
|
|||
|
|
@ -288,8 +288,8 @@ fun MessagesViewContent(
|
|||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MessagesViewTopBar(
|
||||
roomTitle: String?,
|
||||
roomAvatar: AvatarData?,
|
||||
roomTitle: String,
|
||||
roomAvatar: AvatarData,
|
||||
modifier: Modifier = Modifier,
|
||||
onRoomDetailsClicked: () -> Unit = {},
|
||||
onBackPressed: () -> Unit = {},
|
||||
|
|
@ -304,14 +304,12 @@ fun MessagesViewTopBar(
|
|||
modifier = Modifier.clickable { onRoomDetailsClicked() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (roomAvatar != null) {
|
||||
Avatar(roomAvatar)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Avatar(roomAvatar)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
text = roomTitle ?: "Unknown room",
|
||||
text = roomTitle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import android.net.Uri
|
|||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.collect.Iterables.skip
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.features.messages.fixtures.aMessageEvent
|
||||
|
|
@ -82,7 +83,7 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.roomId).isEqualTo(A_ROOM_ID)
|
||||
}
|
||||
|
|
@ -96,7 +97,7 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
|
||||
assertThat(room.myReactions.count()).isEqualTo(1)
|
||||
|
|
@ -117,7 +118,7 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
|
||||
assertThat(room.myReactions.count()).isEqualTo(1)
|
||||
|
|
@ -134,7 +135,7 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
|
|
@ -150,7 +151,7 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Copy, event))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
|
|
@ -164,10 +165,10 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent()))
|
||||
skipItems(1)
|
||||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
|
|
@ -180,7 +181,7 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null)))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
|
|
@ -195,7 +196,7 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
val mediaMessage = aMessageEvent(
|
||||
content = TimelineItemImageContent(
|
||||
|
|
@ -212,7 +213,7 @@ class MessagesPresenterTest {
|
|||
)
|
||||
)
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage))
|
||||
skipItems(1)
|
||||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
||||
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
|
||||
|
|
@ -227,7 +228,7 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
val mediaMessage = aMessageEvent(
|
||||
content = TimelineItemVideoContent(
|
||||
|
|
@ -245,7 +246,7 @@ class MessagesPresenterTest {
|
|||
)
|
||||
)
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage))
|
||||
skipItems(1)
|
||||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
||||
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
|
||||
|
|
@ -260,7 +261,7 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
val mediaMessage = aMessageEvent(
|
||||
content = TimelineItemFileContent(
|
||||
|
|
@ -273,7 +274,7 @@ class MessagesPresenterTest {
|
|||
)
|
||||
)
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage))
|
||||
skipItems(1)
|
||||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
||||
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
|
||||
|
|
@ -288,10 +289,10 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent()))
|
||||
skipItems(1)
|
||||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Edit::class.java)
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
|
|
@ -306,7 +307,7 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent()))
|
||||
assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID)
|
||||
|
|
@ -321,7 +322,7 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent()))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
|
|
@ -335,7 +336,7 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.Dismiss)
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
|
|
@ -349,7 +350,7 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent()))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
|
|
@ -364,7 +365,7 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(3)
|
||||
|
||||
val initialState = awaitItem()
|
||||
|
||||
// Initially the composer doesn't have focus, so we don't show the alert
|
||||
|
|
@ -389,7 +390,7 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(3)
|
||||
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||
|
||||
|
|
@ -406,7 +407,7 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(3)
|
||||
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||
|
||||
|
|
@ -432,9 +433,10 @@ class MessagesPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
|
||||
skipItems(3)
|
||||
|
||||
|
||||
skipItems(1)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.inviteProgress.isLoading()).isTrue()
|
||||
|
||||
|
|
@ -461,9 +463,10 @@ class MessagesPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
|
||||
skipItems(3)
|
||||
|
||||
|
||||
skipItems(1)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.inviteProgress.isLoading()).isTrue()
|
||||
|
||||
|
|
@ -482,9 +485,10 @@ class MessagesPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
|
||||
skipItems(3)
|
||||
|
||||
|
||||
skipItems(1)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.inviteProgress.isLoading()).isTrue()
|
||||
|
||||
|
|
@ -510,9 +514,9 @@ class MessagesPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
|
||||
skipItems(3)
|
||||
|
||||
skipItems(1)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.inviteProgress.isLoading()).isTrue()
|
||||
|
||||
|
|
@ -529,7 +533,7 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
||||
assertThat(awaitItem().userHasPermissionToSendMessage).isTrue()
|
||||
}
|
||||
}
|
||||
|
|
@ -542,6 +546,8 @@ class MessagesPresenterTest {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
// Default value
|
||||
assertThat(awaitItem().userHasPermissionToSendMessage).isTrue()
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().userHasPermissionToSendMessage).isFalse()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
|
||||
return RoomDetailsState(
|
||||
roomId = room.roomId.value,
|
||||
roomName = room.name ?: room.displayName,
|
||||
roomName = room.displayName,
|
||||
roomAlias = room.alias,
|
||||
roomAvatarUrl = room.avatarUrl,
|
||||
roomTopic = topicState,
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ class RoomDetailsPresenterTests {
|
|||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.roomId).isEqualTo(room.roomId.value)
|
||||
assertThat(initialState.roomName).isEqualTo(room.name)
|
||||
assertThat(initialState.roomName).isEqualTo(room.displayName)
|
||||
assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl)
|
||||
assertThat(initialState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(room.topic!!))
|
||||
assertThat(initialState.memberCount).isEqualTo(room.joinedMemberCount)
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ class RoomListPresenter @Inject constructor(
|
|||
// Safe to give bigger size than room list
|
||||
val extendedRangeEnd = range.last + midExtendedRangeSize
|
||||
val extendedRange = IntRange(extendedRangeStart, extendedRangeEnd)
|
||||
client.roomSummaryDataSource.updateRoomListVisibleRange(extendedRange)
|
||||
client.roomSummaryDataSource.updateAllRoomsVisibleRange(extendedRange)
|
||||
}
|
||||
|
||||
private suspend fun mapRoomSummaries(
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
|
|||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.roomListPlaceholder
|
||||
import io.element.android.libraries.designsystem.theme.placeholderBackground
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
/**
|
||||
|
|
@ -56,7 +56,7 @@ internal fun RoomSummaryPlaceholderRow(
|
|||
modifier = Modifier
|
||||
.size(AvatarSize.RoomListItem.dp)
|
||||
.align(Alignment.CenterVertically)
|
||||
.background(color = ElementTheme.colors.roomListPlaceholder, shape = CircleShape)
|
||||
.background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import androidx.compose.ui.unit.Dp
|
|||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.roomListPlaceholder
|
||||
import io.element.android.libraries.designsystem.theme.placeholderBackground
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
@Composable
|
||||
|
|
@ -37,7 +37,7 @@ fun PlaceholderAtom(
|
|||
width: Dp,
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = ElementTheme.colors.roomListPlaceholder,
|
||||
color: Color = ElementTheme.colors.placeholderBackground,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ fun MaterialTheme.roomListRoomMessageDate() = colorScheme.secondary
|
|||
val SemanticColors.unreadIndicator
|
||||
get() = iconAccentTertiary
|
||||
|
||||
val SemanticColors.roomListPlaceholder
|
||||
val SemanticColors.placeholderBackground
|
||||
get() = bgSubtleSecondary
|
||||
|
||||
// This color is not present in Semantic color, so put hard-coded value for now
|
||||
|
|
@ -83,7 +83,7 @@ private fun ContentToPreview() {
|
|||
"roomListRoomMessage" to MaterialTheme.roomListRoomMessage(),
|
||||
"roomListRoomMessageDate" to MaterialTheme.roomListRoomMessageDate(),
|
||||
"unreadIndicator" to ElementTheme.colors.unreadIndicator,
|
||||
"roomListPlaceholder" to ElementTheme.colors.roomListPlaceholder,
|
||||
"placeholderBackground" to ElementTheme.colors.placeholderBackground,
|
||||
"messageFromMeBackground" to ElementTheme.colors.messageFromMeBackground,
|
||||
"messageFromOtherBackground" to ElementTheme.colors.messageFromOtherBackground,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,14 +31,16 @@ import io.element.android.libraries.matrix.api.sync.SyncService
|
|||
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import java.io.Closeable
|
||||
import kotlin.time.Duration
|
||||
|
||||
interface MatrixClient : Closeable {
|
||||
val sessionId: SessionId
|
||||
val roomSummaryDataSource: RoomSummaryDataSource
|
||||
val mediaLoader: MatrixMediaLoader
|
||||
fun getRoom(roomId: RoomId): MatrixRoom?
|
||||
fun findDM(userId: UserId): MatrixRoom?
|
||||
suspend fun getRoom(roomId: RoomId): MatrixRoom?
|
||||
suspend fun findDM(userId: UserId): MatrixRoom?
|
||||
suspend fun ignoreUser(userId: UserId): Result<Unit>
|
||||
suspend fun unignoreUser(userId: UserId): Result<Unit>
|
||||
suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ interface MatrixRoom : Closeable {
|
|||
val sessionId: SessionId
|
||||
val roomId: RoomId
|
||||
val name: String?
|
||||
val bestName: String
|
||||
val displayName: String
|
||||
val alias: String?
|
||||
val alternativeAliases: List<String>
|
||||
|
|
|
|||
|
|
@ -16,17 +16,34 @@
|
|||
|
||||
package io.element.android.libraries.matrix.api.room
|
||||
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration
|
||||
|
||||
interface RoomSummaryDataSource {
|
||||
|
||||
sealed class LoadingState {
|
||||
object NotLoaded : LoadingState()
|
||||
data class Loaded(val numberOfRooms: Int): LoadingState()
|
||||
data class Loaded(val numberOfRooms: Int) : LoadingState()
|
||||
}
|
||||
|
||||
fun updateAllRoomsVisibleRange(range: IntRange)
|
||||
fun allRoomsLoadingState(): StateFlow<LoadingState>
|
||||
fun allRooms(): StateFlow<List<RoomSummary>>
|
||||
fun inviteRooms(): StateFlow<List<RoomSummary>>
|
||||
fun updateRoomListVisibleRange(range: IntRange)
|
||||
}
|
||||
|
||||
suspend fun RoomSummaryDataSource.awaitAllRoomsAreLoaded(timeout: Duration = Duration.INFINITE) {
|
||||
try {
|
||||
withTimeout(timeout) {
|
||||
allRoomsLoadingState().firstOrNull {
|
||||
it is RoomSummaryDataSource.LoadingState.Loaded
|
||||
}
|
||||
}
|
||||
} catch (timeoutException: TimeoutCancellationException) {
|
||||
Timber.v("AwaitAllRooms: no response after $timeout")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import io.element.android.libraries.matrix.api.pusher.PushersService
|
|||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
|
||||
import io.element.android.libraries.matrix.api.room.awaitAllRoomsAreLoaded
|
||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
||||
|
|
@ -63,6 +64,8 @@ import kotlinx.coroutines.withContext
|
|||
import kotlinx.coroutines.withTimeout
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.ClientDelegate
|
||||
import org.matrix.rustcomponents.sdk.Room
|
||||
import org.matrix.rustcomponents.sdk.RoomListItem
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
|
|
@ -91,7 +94,6 @@ class RustMatrixClient constructor(
|
|||
)
|
||||
private val notificationService = RustNotificationService(client)
|
||||
|
||||
|
||||
private val clientDelegate = object : ClientDelegate {
|
||||
override fun didReceiveAuthError(isSoftLogout: Boolean) {
|
||||
//TODO handle this
|
||||
|
|
@ -127,9 +129,16 @@ class RustMatrixClient constructor(
|
|||
}.launchIn(sessionCoroutineScope)
|
||||
}
|
||||
|
||||
override fun getRoom(roomId: RoomId): MatrixRoom? {
|
||||
val roomListItem = roomListService.roomOrNull(roomId.value) ?: return null
|
||||
val fullRoom = roomListItem.fullRoom()
|
||||
override suspend fun getRoom(roomId: RoomId): MatrixRoom? {
|
||||
// Check if already in memory...
|
||||
var cachedPairOfRoom = pairOfRoom(roomId)
|
||||
if (cachedPairOfRoom == null) {
|
||||
//... otherwise, lets wait for the SS to load all rooms and check again.
|
||||
roomSummaryDataSource.awaitAllRoomsAreLoaded()
|
||||
cachedPairOfRoom = pairOfRoom(roomId)
|
||||
}
|
||||
if (cachedPairOfRoom == null) return null
|
||||
val (roomListItem, fullRoom) = cachedPairOfRoom
|
||||
return RustMatrixRoom(
|
||||
sessionId = sessionId,
|
||||
roomListItem = roomListItem,
|
||||
|
|
@ -141,7 +150,17 @@ class RustMatrixClient constructor(
|
|||
)
|
||||
}
|
||||
|
||||
override fun findDM(userId: UserId): MatrixRoom? {
|
||||
private suspend fun pairOfRoom(roomId: RoomId): Pair<RoomListItem, Room>? = withContext(dispatchers.io) {
|
||||
val cachedRoomListItem = roomListService.roomOrNull(roomId.value)
|
||||
val fullRoom = cachedRoomListItem?.fullRoom()
|
||||
if (cachedRoomListItem == null || fullRoom == null) {
|
||||
null
|
||||
} else {
|
||||
Pair(cachedRoomListItem, fullRoom)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun findDM(userId: UserId): MatrixRoom? {
|
||||
val roomId = client.getDmRoom(userId.value)?.use { RoomId(it.id()) }
|
||||
return roomId?.let { getRoom(it) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import org.matrix.rustcomponents.sdk.RoomList
|
|||
import org.matrix.rustcomponents.sdk.RoomListEntriesListener
|
||||
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
|
||||
import org.matrix.rustcomponents.sdk.RoomListEntry
|
||||
import org.matrix.rustcomponents.sdk.RoomListException
|
||||
import org.matrix.rustcomponents.sdk.RoomListItem
|
||||
import org.matrix.rustcomponents.sdk.RoomListLoadingState
|
||||
import org.matrix.rustcomponents.sdk.RoomListLoadingStateListener
|
||||
|
|
@ -60,8 +61,8 @@ fun RoomList.entriesFlow(onInitialList: suspend (List<RoomListEntry>) -> Unit):
|
|||
fun RoomListService.roomOrNull(roomId: String): RoomListItem? {
|
||||
return try {
|
||||
room(roomId)
|
||||
} catch (failure: Throwable) {
|
||||
Timber.e(failure, "Failed finding room with id=$roomId")
|
||||
} catch (exception: RoomListException) {
|
||||
Timber.e(exception, "Failed finding room with id=$roomId")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,11 +132,6 @@ class RustMatrixRoom(
|
|||
return roomListItem.name()
|
||||
}
|
||||
|
||||
override val bestName: String
|
||||
get() {
|
||||
return name?.takeIf { it.isNotEmpty() } ?: innerRoom.id()
|
||||
}
|
||||
|
||||
override val displayName: String
|
||||
get() {
|
||||
return innerRoom.displayName()
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ internal class RustRoomSummaryDataSource(
|
|||
return allRoomsLoadingState
|
||||
}
|
||||
|
||||
override fun updateRoomListVisibleRange(range: IntRange) {
|
||||
override fun updateAllRoomsVisibleRange(range: IntRange) {
|
||||
Timber.v("setVisibleRange=$range")
|
||||
sessionCoroutineScope.launch {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -64,11 +64,11 @@ class FakeMatrixClient(
|
|||
private val getProfileResults = mutableMapOf<UserId, Result<MatrixUser>>()
|
||||
private var uploadMediaResult: Result<String> = Result.success(AN_AVATAR_URL)
|
||||
|
||||
override fun getRoom(roomId: RoomId): MatrixRoom? {
|
||||
override suspend fun getRoom(roomId: RoomId): MatrixRoom? {
|
||||
return getRoomResults[roomId]
|
||||
}
|
||||
|
||||
override fun findDM(userId: UserId): MatrixRoom? {
|
||||
override suspend fun findDM(userId: UserId): MatrixRoom? {
|
||||
return findDmResult
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ class FakeMatrixRoom(
|
|||
override val sessionId: SessionId = A_SESSION_ID,
|
||||
override val roomId: RoomId = A_ROOM_ID,
|
||||
override val name: String? = null,
|
||||
override val bestName: String = "",
|
||||
override val displayName: String = "",
|
||||
override val topic: String? = null,
|
||||
override val avatarUrl: String? = null,
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class FakeRoomSummaryDataSource : RoomSummaryDataSource {
|
|||
var latestSlidingSyncRange: IntRange? = null
|
||||
private set
|
||||
|
||||
override fun updateRoomListVisibleRange(range: IntRange) {
|
||||
override fun updateAllRoomsVisibleRange(range: IntRange) {
|
||||
latestSlidingSyncRange = range
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
|
|||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.roomListPlaceholder
|
||||
import io.element.android.libraries.designsystem.theme.placeholderBackground
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
@Composable
|
||||
|
|
@ -53,7 +53,7 @@ fun MatrixUserHeaderPlaceholder(
|
|||
modifier = Modifier
|
||||
.padding(vertical = 12.dp)
|
||||
.size(AvatarSize.UserPreference.dp)
|
||||
.background(color = ElementTheme.colors.roomListPlaceholder, shape = CircleShape)
|
||||
.background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:863eaa52c2c0f684d29ed6d6618f4fcd18c953381d6624bdeb70a484bb37520e
|
||||
size 36819
|
||||
oid sha256:e6a92140902cde5caf356028c3f67e4d9813672b4799c079de4b634b12f739ba
|
||||
size 37014
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6523908f527a20ac68e3dad227f5987e348411629c045ea2a9af2cfe48a66867
|
||||
size 36517
|
||||
oid sha256:32d3246e87a7ce66191f9a84dc6b0b3106e044d510fdb0b69bad9260f7be6d4e
|
||||
size 36716
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue