Create spaces (#5982)

* Allow creating a space with `CreateRoomParameters`

* Add 'Create space' menu item in the spaces home screen. Also, imports new strings related to spaces.

* Link the 'Create space' button with the screen to create the space

* Unify room access and visibility for `ConfigureRoom`, use the updated design

* Fix `EditRoomDetails` avatar size (68dp)

* Replace `EditableAvatarView` and `UnsavedAvatar` copmonents with `AvatarPickerView`

* `AvatarDataFetcherFactory`: Make sure we use a fallback image fetcher when the URL is not an MXC one (a local one, i.e.). This removes the previous need for a separate `UnsavedAvatarView`

* Use `AvatarPickerView` in all the screens where `EditableAvatarView` was used

* Improve naming and previews

* Update strings, remove unused ones for `RoomAccessItem`

* Make `isSpace` part of the `CreateRoomConfig`

* Ensure the content fits in the screenshots for `AvatarPickerSizesPreview`

* Add `AvatarDataFetcherFactoryTest`

* Add new feature flag for creating spaces

* Fix ripple being too large for the `Pick` state

* Tweak margins and section titles a bit

* Add preview for `HomeTopBar` with the spaces case

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2026-01-13 14:35:49 +01:00 committed by GitHub
parent 983c012b79
commit 6d1ed5967b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
150 changed files with 1097 additions and 778 deletions

View file

@ -220,6 +220,7 @@ class HomeFlowNode(
onRoomClick = ::navigateToRoom,
onSettingsClick = callback::navigateToSettings,
onStartChatClick = callback::navigateToCreateRoom,
onCreateSpaceClick = callback::navigateToCreateSpace,
onSetUpRecoveryClick = callback::navigateToSetUpRecovery,
onConfirmRecoveryKeyClick = callback::navigateToEnterRecoveryKey,
onRoomSettingsClick = callback::navigateToRoomSettings,

View file

@ -74,6 +74,7 @@ fun HomeView(
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onStartChatClick: () -> Unit,
onCreateSpaceClick: () -> Unit,
onRoomSettingsClick: (roomId: RoomId) -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
onReportRoomClick: (roomId: RoomId) -> Unit,
@ -113,6 +114,7 @@ fun HomeView(
onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) },
onOpenSettings = { if (firstThrottler.canHandle()) onSettingsClick() },
onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() },
onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() },
onMenuActionClick = onMenuActionClick,
)
// This overlaid view will only be visible when state.displaySearchResults is true
@ -138,6 +140,7 @@ private fun HomeScaffold(
onRoomClick: (RoomId) -> Unit,
onOpenSettings: () -> Unit,
onStartChatClick: () -> Unit,
onCreateSpaceClick: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
modifier: Modifier = Modifier,
) {
@ -164,6 +167,7 @@ private fun HomeScaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
HomeTopBar(
selectedNavigationItem = state.currentHomeNavigationBarItem,
title = stringResource(state.currentHomeNavigationBarItem.labelRes),
currentUserAndNeighbors = state.currentUserAndNeighbors,
showAvatarIndicator = state.showAvatarIndicator,
@ -174,10 +178,11 @@ private fun HomeScaffold(
onAccountSwitch = {
state.eventSink(HomeEvents.SwitchToAccount(it))
},
onCreateSpace = onCreateSpaceClick,
scrollBehavior = scrollBehavior,
displayMenuItems = state.displayActions,
displayFilters = state.displayRoomListFilters,
filtersState = roomListState.filtersState,
canCreateSpaces = state.homeSpacesState.canCreateSpaces,
canReportBug = state.canReportBug,
modifier = Modifier.hazeEffect(
state = hazeState,
@ -328,6 +333,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state:
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
onCreateSpaceClick = {},
onRoomSettingsClick = {},
onReportRoomClick = {},
onMenuActionClick = {},
@ -347,6 +353,7 @@ internal fun HomeViewA11yPreview() = ElementPreview {
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
onCreateSpaceClick = {},
onRoomSettingsClick = {},
onReportRoomClick = {},
onMenuActionClick = {},

View file

@ -39,6 +39,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.HomeNavigationBarItem
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.filters.RoomListFiltersView
@ -73,6 +74,7 @@ import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeTopBar(
selectedNavigationItem: HomeNavigationBarItem,
title: String,
currentUserAndNeighbors: ImmutableList<MatrixUser>,
showAvatarIndicator: Boolean,
@ -81,8 +83,9 @@ fun HomeTopBar(
onMenuActionClick: (RoomListMenuAction) -> Unit,
onOpenSettings: () -> Unit,
onAccountSwitch: (SessionId) -> Unit,
onCreateSpace: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
displayMenuItems: Boolean,
canCreateSpaces: Boolean,
canReportBug: Boolean,
displayFilters: Boolean,
filtersState: RoomListFiltersState,
@ -117,63 +120,16 @@ fun HomeTopBar(
)
},
actions = {
if (displayMenuItems) {
IconButton(
onClick = onToggleSearch,
) {
Icon(
imageVector = CompoundIcons.Search(),
contentDescription = stringResource(CommonStrings.action_search),
)
}
if (RoomListConfig.HAS_DROP_DOWN_MENU) {
var showMenu by remember { mutableStateOf(false) }
IconButton(
onClick = { showMenu = !showMenu }
) {
Icon(
imageVector = CompoundIcons.OverflowVertical(),
contentDescription = null,
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
if (RoomListConfig.SHOW_INVITE_MENU_ITEM) {
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClick(RoomListMenuAction.InviteFriends)
},
text = { Text(stringResource(id = CommonStrings.action_invite)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
tint = ElementTheme.colors.iconSecondary,
contentDescription = null,
)
}
)
}
if (RoomListConfig.SHOW_REPORT_PROBLEM_MENU_ITEM && canReportBug) {
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClick(RoomListMenuAction.ReportBug)
},
text = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.ChatProblem(),
tint = ElementTheme.colors.iconSecondary,
contentDescription = null,
)
}
)
}
}
}
when (selectedNavigationItem) {
HomeNavigationBarItem.Chats -> RoomListMenuItems(
onToggleSearch = onToggleSearch,
onMenuActionClick = onMenuActionClick,
canReportBug = canReportBug
)
HomeNavigationBarItem.Spaces -> SpacesMenuItems(
canCreateSpaces = canCreateSpaces,
onCreateSpace = onCreateSpace
)
}
},
// We want a 16dp left padding for the navigationIcon :
@ -193,6 +149,85 @@ fun HomeTopBar(
}
}
@Composable
private fun RoomListMenuItems(
onToggleSearch: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
canReportBug: Boolean,
) {
IconButton(
onClick = onToggleSearch,
) {
Icon(
imageVector = CompoundIcons.Search(),
contentDescription = stringResource(CommonStrings.action_search),
)
}
if (RoomListConfig.HAS_DROP_DOWN_MENU) {
var showMenu by remember { mutableStateOf(false) }
IconButton(
onClick = { showMenu = !showMenu }
) {
Icon(
imageVector = CompoundIcons.OverflowVertical(),
contentDescription = null,
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
if (RoomListConfig.SHOW_INVITE_MENU_ITEM) {
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClick(RoomListMenuAction.InviteFriends)
},
text = { Text(stringResource(id = CommonStrings.action_invite)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
tint = ElementTheme.colors.iconSecondary,
contentDescription = null,
)
}
)
}
if (RoomListConfig.SHOW_REPORT_PROBLEM_MENU_ITEM && canReportBug) {
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClick(RoomListMenuAction.ReportBug)
},
text = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.ChatProblem(),
tint = ElementTheme.colors.iconSecondary,
contentDescription = null,
)
}
)
}
}
}
}
@Composable
private fun SpacesMenuItems(
canCreateSpaces: Boolean,
onCreateSpace: () -> Unit
) {
if (canCreateSpaces) {
IconButton(onClick = onCreateSpace) {
Icon(
imageVector = CompoundIcons.Plus(),
contentDescription = stringResource(CommonStrings.action_create_space)
)
}
}
}
@Composable
private fun NavigationIcon(
currentUserAndNeighbors: ImmutableList<MatrixUser>,
@ -273,6 +308,7 @@ private fun AccountIcon(
@Composable
internal fun HomeTopBarPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
title = stringResource(R.string.screen_roomlist_main_space_title),
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
showAvatarIndicator = false,
@ -281,7 +317,8 @@ internal fun HomeTopBarPreview() = ElementPreview {
onOpenSettings = {},
onAccountSwitch = {},
onToggleSearch = {},
displayMenuItems = true,
onCreateSpace = {},
canCreateSpaces = true,
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
@ -289,11 +326,35 @@ internal fun HomeTopBarPreview() = ElementPreview {
)
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
internal fun HomeTopBarSpacesPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Spaces,
title = stringResource(R.string.screen_home_tab_spaces),
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
onOpenSettings = {},
onAccountSwitch = {},
onToggleSearch = {},
onCreateSpace = {},
canCreateSpaces = true,
canReportBug = true,
displayFilters = false,
filtersState = aRoomListFiltersState(),
onMenuActionClick = {},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
title = stringResource(R.string.screen_roomlist_main_space_title),
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
showAvatarIndicator = true,
@ -302,7 +363,8 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
onOpenSettings = {},
onAccountSwitch = {},
onToggleSearch = {},
displayMenuItems = true,
onCreateSpace = {},
canCreateSpaces = true,
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
@ -315,6 +377,7 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
@Composable
internal fun HomeTopBarMultiAccountPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
title = stringResource(R.string.screen_roomlist_main_space_title),
currentUserAndNeighbors = aMatrixUserList().take(3).toImmutableList(),
showAvatarIndicator = false,
@ -323,7 +386,8 @@ internal fun HomeTopBarMultiAccountPreview() = ElementPreview {
onOpenSettings = {},
onAccountSwitch = {},
onToggleSearch = {},
displayMenuItems = true,
onCreateSpace = {},
canCreateSpaces = true,
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),

View file

@ -15,6 +15,8 @@ import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
import kotlinx.collections.immutable.persistentListOf
@ -27,9 +29,11 @@ import kotlinx.coroutines.flow.map
class HomeSpacesPresenter(
private val client: MatrixClient,
private val seenInvitesStore: SeenInvitesStore,
private val featureFlagsService: FeatureFlagService,
) : Presenter<HomeSpacesState> {
@Composable
override fun present(): HomeSpacesState {
val canCreateSpaces by featureFlagsService.isFeatureEnabledFlow(FeatureFlags.CreateSpaces).collectAsState(false)
val hideInvitesAvatar by client.rememberHideInvitesAvatar()
val spaceRooms by remember {
client.spaceService.spaceRoomsFlow.map { it.toImmutableList() }
@ -48,6 +52,7 @@ class HomeSpacesPresenter(
spaceRooms = spaceRooms,
seenSpaceInvites = seenSpaceInvites,
hideInvitesAvatar = hideInvitesAvatar,
canCreateSpaces = canCreateSpaces,
eventSink = ::handleEvent,
)
}

View file

@ -18,6 +18,7 @@ data class HomeSpacesState(
val spaceRooms: ImmutableList<SpaceRoom>,
val seenSpaceInvites: ImmutableSet<RoomId>,
val hideInvitesAvatar: Boolean,
val canCreateSpaces: Boolean,
val eventSink: (HomeSpacesEvents) -> Unit,
)

View file

@ -30,6 +30,13 @@ open class HomeSpacesStateProvider : PreviewParameterProvider<HomeSpacesState> {
),
spaceRooms = aListOfSpaceRooms(),
),
aHomeSpacesState(
space = CurrentSpace.Space(
spaceRoom = aSpaceRoom(roomId = RoomId("!mySpace:example.com"))
),
spaceRooms = aListOfSpaceRooms(),
canCreateSpaces = false,
),
)
}
@ -38,12 +45,14 @@ internal fun aHomeSpacesState(
spaceRooms: List<SpaceRoom> = aListOfSpaceRooms(),
seenSpaceInvites: Set<RoomId> = emptySet(),
hideInvitesAvatar: Boolean = false,
canCreateSpaces: Boolean = true,
eventSink: (HomeSpacesEvents) -> Unit = {},
) = HomeSpacesState(
space = space,
spaceRooms = spaceRooms.toImmutableList(),
seenSpaceInvites = seenSpaceInvites.toImmutableSet(),
hideInvitesAvatar = hideInvitesAvatar,
canCreateSpaces = canCreateSpaces,
eventSink = eventSink,
)

View file

@ -47,6 +47,7 @@ class DefaultHomeEntryPointTest {
val callback = object : HomeEntryPoint.Callback {
override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) = lambdaError()
override fun navigateToCreateRoom() = lambdaError()
override fun navigateToCreateSpace() = lambdaError()
override fun navigateToSettings() = lambdaError()
override fun navigateToSetUpRecovery() = lambdaError()
override fun navigateToEnterRecoveryKey() = lambdaError()

View file

@ -273,6 +273,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
onSetUpRecoveryClick: () -> Unit = EnsureNeverCalled(),
onConfirmRecoveryKeyClick: () -> Unit = EnsureNeverCalled(),
onCreateRoomClick: () -> Unit = EnsureNeverCalled(),
onCreateSpaceClick: () -> Unit = EnsureNeverCalled(),
onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
@ -286,6 +287,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onStartChatClick = onCreateRoomClick,
onCreateSpaceClick = onCreateSpaceClick,
onRoomSettingsClick = onRoomSettingsClick,
onMenuActionClick = onMenuActionClick,
onDeclineInviteAndBlockUser = onDeclineInviteAndBlockUser,

View file

@ -11,6 +11,9 @@ package io.element.android.features.home.impl.spaces
import com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.test
@ -23,18 +26,25 @@ class HomeSpacesPresenterTest {
val presenter = createPresenter()
presenter.test {
val state = awaitItem()
// canCreateSpaces is initially false
assertThat(state.canCreateSpaces).isFalse()
assertThat(state.space).isEqualTo(CurrentSpace.Root)
assertThat(state.spaceRooms).isEmpty()
assertThat(state.hideInvitesAvatar).isFalse()
assertThat(state.seenSpaceInvites).isEmpty()
// It'll eventually be true
assertThat(awaitItem().canCreateSpaces).isTrue()
}
}
private fun createPresenter(
client: MatrixClient = FakeMatrixClient(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
featureFlagsService: FeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.CreateSpaces.key to true)),
) = HomeSpacesPresenter(
client = client,
seenInvitesStore = seenInvitesStore,
featureFlagsService = featureFlagsService,
)
}