Merge pull request #5493 from element-hq/feature/fga/space_description

feature(space): make sure to handle topic properly
This commit is contained in:
ganfra
2025-10-09 15:39:15 +02:00
committed by GitHub
24 changed files with 239 additions and 57 deletions

View File

@@ -15,4 +15,7 @@ sealed interface SpaceEvents {
data object ClearFailures : SpaceEvents
data class AcceptInvite(val spaceRoom: SpaceRoom) : SpaceEvents
data class DeclineInvite(val spaceRoom: SpaceRoom) : SpaceEvents
data class ShowTopicViewer(val topic: String) : SpaceEvents
data object HideTopicViewer : SpaceEvents
}

View File

@@ -81,6 +81,8 @@ class SpacePresenter(
val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState()
val (joinActions, setJoinActions) = remember { mutableStateOf(emptyMap<RoomId, AsyncAction<Unit>>()) }
var topicViewerState: TopicViewerState by remember { mutableStateOf(TopicViewerState.Hidden) }
LaunchedEffect(children) {
// Remove joined children from the join actions
val joinedChildren = children
@@ -113,6 +115,8 @@ class SpacePresenter(
AcceptDeclineInviteEvents.DeclineInvite(invite = event.spaceRoom.toInviteData(), shouldConfirm = true, blockUser = false)
)
}
SpaceEvents.HideTopicViewer -> topicViewerState = TopicViewerState.Hidden
is SpaceEvents.ShowTopicViewer -> topicViewerState = TopicViewerState.Shown(event.topic)
}
}
return SpaceState(
@@ -123,6 +127,7 @@ class SpacePresenter(
hasMoreToLoad = hasMoreToLoad,
joinActions = joinActions.toImmutableMap(),
acceptDeclineInviteState = acceptDeclineInviteState,
topicViewerState = topicViewerState,
eventSink = ::handleEvents,
)
}

View File

@@ -7,6 +7,7 @@
package io.element.android.features.space.impl.root
import androidx.compose.runtime.Immutable
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
@@ -23,6 +24,7 @@ data class SpaceState(
val hasMoreToLoad: Boolean,
val joinActions: ImmutableMap<RoomId, AsyncAction<Unit>>,
val acceptDeclineInviteState: AcceptDeclineInviteState,
val topicViewerState: TopicViewerState,
val eventSink: (SpaceEvents) -> Unit
) {
fun isJoining(spaceId: RoomId): Boolean = joinActions[spaceId] == AsyncAction.Loading
@@ -30,3 +32,9 @@ data class SpaceState(
it is AsyncAction.Failure
}
}
@Immutable
sealed interface TopicViewerState {
data object Hidden : TopicViewerState
data class Shown(val topic: String) : TopicViewerState
}

View File

@@ -8,6 +8,7 @@
package io.element.android.features.space.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.libraries.architecture.AsyncAction
@@ -25,51 +26,32 @@ open class SpaceStateProvider : PreviewParameterProvider<SpaceState> {
override val values: Sequence<SpaceState>
get() = sequenceOf(
aSpaceState(),
aSpaceState(parentSpace = aParentSpace(joinRule = JoinRule.Public)),
aSpaceState(parentSpace = aParentSpace(joinRule = JoinRule.Restricted(persistentListOf()))),
aSpaceState(children = aListOfSpaceRooms()),
aSpaceState(
parentSpace = aSpaceRoom(
joinRule = JoinRule.Public
)
),
aSpaceState(
parentSpace = aSpaceRoom(
joinRule = JoinRule.Restricted(persistentListOf())
)
),
aSpaceState(
parentSpace = aSpaceRoom(
numJoinedMembers = 5,
childrenCount = 10,
worldReadable = true,
),
hasMoreToLoad = true,
),
aSpaceState(
hasMoreToLoad = true,
children = aListOfSpaceRooms(),
),
aSpaceState(
hasMoreToLoad = false,
parentSpace = aParentSpace(),
children = aListOfSpaceRooms(),
joiningRooms = setOf(RoomId("!spaceId0:example.com")),
)
hasMoreToLoad = false
),
aSpaceState(
topicViewerState = TopicViewerState.Shown(topic = "Space description goes here." + LoremIpsum(20).values.first()),
),
// Add other states here
)
}
fun aSpaceState(
parentSpace: SpaceRoom? = aSpaceRoom(
numJoinedMembers = 5,
childrenCount = 10,
worldReadable = true,
roomId = RoomId("!spaceId0:example.com"),
),
parentSpace: SpaceRoom? = aParentSpace(),
children: List<SpaceRoom> = emptyList(),
seenSpaceInvites: Set<RoomId> = emptySet(),
joiningRooms: Set<RoomId> = emptySet(),
joinActions: Map<RoomId, AsyncAction<Unit>> = joiningRooms.associateWith { AsyncAction.Loading },
hideInvitesAvatar: Boolean = false,
hasMoreToLoad: Boolean = false,
hasMoreToLoad: Boolean = true,
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
topicViewerState: TopicViewerState = TopicViewerState.Hidden,
eventSink: (SpaceEvents) -> Unit = { },
) = SpaceState(
currentSpace = parentSpace,
@@ -79,9 +61,23 @@ fun aSpaceState(
hasMoreToLoad = hasMoreToLoad,
joinActions = joinActions.toImmutableMap(),
acceptDeclineInviteState = acceptDeclineInviteState,
topicViewerState = topicViewerState,
eventSink = eventSink,
)
private fun aParentSpace(
joinRule: JoinRule? = null,
): SpaceRoom {
return aSpaceRoom(
numJoinedMembers = 5,
childrenCount = 10,
worldReadable = true,
joinRule = joinRule,
roomId = RoomId("!spaceId0:example.com"),
topic = "Space description goes here. " + LoremIpsum(20).values.first(),
)
}
private fun aListOfSpaceRooms(): List<SpaceRoom> {
return listOf(
aSpaceRoom(

View File

@@ -7,6 +7,7 @@
package io.element.android.features.space.impl.root
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@@ -33,6 +34,8 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.components.SimpleModalBottomSheet
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
@@ -61,6 +64,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SpaceView(
state: SpaceState,
@@ -87,7 +91,10 @@ fun SpaceView(
) {
SpaceViewContent(
state = state,
onRoomClick = onRoomClick
onRoomClick = onRoomClick,
onTopicClick = { topic ->
state.eventSink(SpaceEvents.ShowTopicViewer(topic))
}
)
JoinRoomFailureEffect(
hasAnyFailure = state.hasAnyFailure,
@@ -97,6 +104,14 @@ fun SpaceView(
}
},
)
if (state.topicViewerState is TopicViewerState.Shown) {
TopicViewerBottomSheet(
topicViewerState = state.topicViewerState,
onDismiss = {
state.eventSink(SpaceEvents.HideTopicViewer)
}
)
}
}
@Composable
@@ -120,10 +135,31 @@ private fun JoinRoomFailureEffect(
}
}
@Composable
private fun TopicViewerBottomSheet(
topicViewerState: TopicViewerState.Shown,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
SimpleModalBottomSheet(
title = stringResource(CommonStrings.common_description),
onDismiss = onDismiss,
modifier = modifier
) {
ClickableLinkText(
text = topicViewerState.topic,
interactionSource = remember { MutableInteractionSource() },
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
@Composable
private fun SpaceViewContent(
state: SpaceState,
onRoomClick: (spaceRoom: SpaceRoom) -> Unit,
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier.fillMaxSize()) {
@@ -134,9 +170,11 @@ private fun SpaceViewContent(
avatarData = currentSpace.getAvatarData(AvatarSize.SpaceHeader),
name = currentSpace.displayName,
topic = currentSpace.topic,
topicMaxLines = 2,
visibility = currentSpace.visibility,
heroes = currentSpace.heroes.toImmutableList(),
numberOfMembers = currentSpace.numJoinedMembers,
onTopicClick = onTopicClick
)
}
}

View File

@@ -60,6 +60,7 @@ class SpacePresenterTest {
assertThat(state.hasMoreToLoad).isTrue()
assertThat(state.joinActions).isEmpty()
assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState())
assertThat(state.topicViewerState).isEqualTo(TopicViewerState.Hidden)
advanceUntilIdle()
paginateResult.assertions().isCalledOnce()
}
@@ -236,6 +237,24 @@ class SpacePresenterTest {
}
}
@Test
fun `present - topic viewer state`() = runTest {
val paginateResult = lambdaRecorder<Result<Unit>> {
Result.success(Unit)
}
val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult)
val presenter = createSpacePresenter(spaceRoomList = spaceRoomList)
presenter.test {
val state = awaitItem()
assertThat(state.topicViewerState).isEqualTo(TopicViewerState.Hidden)
advanceUntilIdle()
state.eventSink(SpaceEvents.ShowTopicViewer("topic"))
assertThat(awaitItem().topicViewerState).isEqualTo(TopicViewerState.Shown("topic"))
state.eventSink(SpaceEvents.HideTopicViewer)
assertThat(awaitItem().topicViewerState).isEqualTo(TopicViewerState.Hidden)
}
}
@Test
fun `present - accept invite is transmitted to acceptDeclineInviteState`() {
`invite action is transmitted to acceptDeclineInviteState`(

View File

@@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_ROOM_TOPIC
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
@@ -31,6 +32,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class SpaceViewTest {
@@ -42,6 +44,7 @@ class SpaceViewTest {
ensureCalledOnce {
rule.setSpaceView(
aSpaceState(
hasMoreToLoad = false,
eventSink = eventsRecorder,
),
onBackClick = it,
@@ -58,6 +61,7 @@ class SpaceViewTest {
rule.setSpaceView(
aSpaceState(
children = listOf(aSpaceRoom),
hasMoreToLoad = false,
eventSink = eventsRecorder,
),
onRoomClick = it,
@@ -73,6 +77,7 @@ class SpaceViewTest {
rule.setSpaceView(
aSpaceState(
children = listOf(aSpaceRoom),
hasMoreToLoad = false,
eventSink = eventsRecorder,
),
)
@@ -80,12 +85,14 @@ class SpaceViewTest {
eventsRecorder.assertSingle(SpaceEvents.Join(aSpaceRoom))
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on accept invite emits the expected Event`() {
val aSpaceRoom = aSpaceRoom(roomId = A_ROOM_ID, state = CurrentUserMembership.INVITED)
val eventsRecorder = EventsRecorder<SpaceEvents>()
rule.setSpaceView(
aSpaceState(
hasMoreToLoad = false,
children = listOf(aSpaceRoom),
eventSink = eventsRecorder,
),
@@ -94,12 +101,14 @@ class SpaceViewTest {
eventsRecorder.assertSingle(SpaceEvents.AcceptInvite(aSpaceRoom))
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on decline invite emits the expected Event`() {
val aSpaceRoom = aSpaceRoom(roomId = A_ROOM_ID, state = CurrentUserMembership.INVITED)
val eventsRecorder = EventsRecorder<SpaceEvents>()
rule.setSpaceView(
aSpaceState(
hasMoreToLoad = false,
children = listOf(aSpaceRoom),
eventSink = eventsRecorder,
),
@@ -107,6 +116,21 @@ class SpaceViewTest {
rule.clickOn(CommonStrings.action_decline)
eventsRecorder.assertSingle(SpaceEvents.DeclineInvite(aSpaceRoom))
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on topic emits the expected Event`() {
val eventsRecorder = EventsRecorder<SpaceEvents>()
rule.setSpaceView(
aSpaceState(
parentSpace = aSpaceRoom(topic = A_ROOM_TOPIC),
hasMoreToLoad = false,
eventSink = eventsRecorder,
)
)
rule.onNodeWithText(A_ROOM_TOPIC).performClick()
eventsRecorder.assertSingle(SpaceEvents.ShowTopicViewer(A_ROOM_TOPIC))
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpaceView(

View File

@@ -20,6 +20,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
@@ -51,6 +52,7 @@ fun ClickableLinkText(
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
style: TextStyle = LocalTextStyle.current,
color: Color = Color.Unspecified,
inlineContent: ImmutableMap<String, InlineTextContent> = persistentMapOf(),
) {
ClickableLinkText(
@@ -62,6 +64,7 @@ fun ClickableLinkText(
onClick = onClick,
onLongClick = onLongClick,
style = style,
color = color,
inlineContent = inlineContent,
)
}
@@ -76,6 +79,7 @@ fun ClickableLinkText(
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
style: TextStyle = LocalTextStyle.current,
color: Color = Color.Unspecified,
inlineContent: ImmutableMap<String, InlineTextContent> = persistentMapOf(),
) {
@Suppress("NAME_SHADOWING")
@@ -126,6 +130,7 @@ fun ClickableLinkText(
text = annotatedString,
modifier = modifier.then(pressIndicator),
style = style,
color = color,
onTextLayout = {
layoutResult.value = it
},

View File

@@ -0,0 +1,67 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SimpleModalBottomSheet(
title: String,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
modifier = modifier,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Text(
title,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
Spacer(Modifier.height(8.dp))
content()
}
}
}
@PreviewsDayNight
@Composable
internal fun SimpleModalBottomSheetPreview() = ElementPreview {
SimpleModalBottomSheet(title = "A title", onDismiss = {}) {
Text(
text = LoremIpsum(20).values.first(),
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdRegular,
)
}
}

View File

@@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -43,6 +44,7 @@ fun SpaceHeaderView(
numberOfMembers: Int,
modifier: Modifier = Modifier,
topicMaxLines: Int = Int.MAX_VALUE,
onTopicClick: ((String) -> Unit)? = null,
) {
RoomPreviewOrganism(
modifier = modifier.padding(24.dp),
@@ -68,7 +70,16 @@ fun SpaceHeaderView(
description = if (topic.isNullOrBlank()) {
null
} else {
{ RoomPreviewDescriptionAtom(description = topic, maxLines = topicMaxLines) }
{
RoomPreviewDescriptionAtom(
description = topic,
maxLines = topicMaxLines,
modifier = Modifier.clickable(
enabled = onTopicClick != null,
onClick = { onTopicClick?.invoke(topic) }
)
)
}
},
memberCount = {
SpaceMembersView(