Create a new room when inviting people in a DM (#6756)

* Create a new room when inviting people to a DM

* Improve screenshot tests

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2026-05-18 19:01:11 +02:00 committed by GitHub
parent f55b4d1283
commit f678dffaed
26 changed files with 220 additions and 34 deletions

View file

@ -11,4 +11,5 @@ package io.element.android.features.invitepeople.api
interface InvitePeopleEvents {
data object SendInvites : InvitePeopleEvents
data object CloseSearch : InvitePeopleEvents
data object ClearError : InvitePeopleEvents
}

View file

@ -9,10 +9,12 @@
package io.element.android.features.invitepeople.api
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
interface InvitePeopleState {
val canInvite: Boolean
val isSearchActive: Boolean
val sendInvitesAction: AsyncAction<Unit>
val createRoomFromDmAction: AsyncAction<RoomId>
val eventSink: (InvitePeopleEvents) -> Unit
}

View file

@ -10,6 +10,7 @@ package io.element.android.features.invitepeople.api
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
class InvitePeopleStateProvider : PreviewParameterProvider<InvitePeopleState> {
override val values: Sequence<InvitePeopleState>
@ -25,6 +26,7 @@ private data class PreviewInvitePeopleState(
override val canInvite: Boolean,
override val isSearchActive: Boolean,
override val sendInvitesAction: AsyncAction<Unit>,
override val createRoomFromDmAction: AsyncAction<RoomId>,
override val eventSink: (InvitePeopleEvents) -> Unit,
) : InvitePeopleState
@ -32,10 +34,12 @@ private fun aPreviewInvitePeopleState(
canInvite: Boolean = false,
isSearchActive: Boolean = false,
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
createRoomFromDmAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
eventSink: (InvitePeopleEvents) -> Unit = {},
) = PreviewInvitePeopleState(
canInvite = canInvite,
isSearchActive = isSearchActive,
sendInvitesAction = sendInvitesAction,
createRoomFromDmAction = createRoomFromDmAction,
eventSink = eventSink
)

View file

@ -38,12 +38,17 @@ import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.filterMembers
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.recent.getRecentDirectRooms
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.usersearch.api.UserRepository
@ -88,6 +93,7 @@ class DefaultInvitePeoplePresenter(
var searchActive by rememberSaveable { mutableStateOf(false) }
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
val sendInvitesAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val createRoomFromDmAction = remember { mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized) }
val recentDirectRooms by produceState(emptyList(), roomMembers.value) {
if (roomMembers.value.isSuccess()) {
@ -208,7 +214,13 @@ class DefaultInvitePeoplePresenter(
)
} else {
room.dataOrNull()?.let {
sessionCoroutineScope.sendInvites(it, selectedUsers.value, sendInvitesAction)
sessionCoroutineScope.launch {
if (it.isDm()) {
createRoomFromDm(it, selectedUsers.value, createRoomFromDmAction)
} else {
sendInvites(it, selectedUsers.value, sendInvitesAction)
}
}
}
}
}
@ -216,6 +228,10 @@ class DefaultInvitePeoplePresenter(
searchActive = false
queryState.clearText()
}
is InvitePeopleEvents.ClearError -> {
sendInvitesAction.value = AsyncAction.Uninitialized
createRoomFromDmAction.value = AsyncAction.Uninitialized
}
}
}
@ -228,6 +244,7 @@ class DefaultInvitePeoplePresenter(
searchResults = searchResults.value,
showSearchLoader = showSearchLoader.value,
sendInvitesAction = sendInvitesAction.value,
createRoomFromDmAction = createRoomFromDmAction.value,
suggestions = suggestions,
eventSink = ::handleEvent,
)
@ -254,6 +271,35 @@ class DefaultInvitePeoplePresenter(
}
}
private fun CoroutineScope.createRoomFromDm(
currentRoom: JoinedRoom,
selectedUsers: List<MatrixUser>,
createRoomFromDmAction: MutableState<AsyncAction<RoomId>>,
) = launch {
createRoomFromDmAction.runUpdatingState {
val currentUsers = currentRoom.getMembers(limit = 100).getOrNull().orEmpty()
.filter { it.membership.isActive() }
val invitees = (currentUsers.map { it.userId } + selectedUsers.map { it.userId })
.filter { it != matrixClient.sessionId }
.distinct()
matrixClient.createRoom(
CreateRoomParameters(
name = null,
topic = null,
isEncrypted = true,
isDirect = false,
visibility = RoomVisibility.Private,
preset = RoomPreset.PRIVATE_CHAT,
invite = invitees,
avatar = null,
joinRuleOverride = JoinRule.Invite,
historyVisibilityOverride = RoomHistoryVisibility.Invited,
isSpace = false,
)
)
}
}
@JvmName("toggleUserInSelectedUsers")
private fun MutableState<ImmutableList<MatrixUser>>.toggleUser(user: MatrixUser) {
value = if (value.contains(user)) {

View file

@ -14,6 +14,7 @@ import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
@ -26,6 +27,7 @@ data class DefaultInvitePeopleState(
val selectedUsers: ImmutableList<MatrixUser>,
override val isSearchActive: Boolean,
override val sendInvitesAction: AsyncAction<Unit>,
override val createRoomFromDmAction: AsyncAction<RoomId>,
val suggestions: ImmutableList<InvitableUser>,
override val eventSink: (InvitePeopleEvents) -> Unit
) : InvitePeopleState

View file

@ -18,6 +18,7 @@ import io.element.android.libraries.designsystem.preview.USER_NAME_CAROL
import io.element.android.libraries.designsystem.preview.USER_NAME_EVE
import io.element.android.libraries.designsystem.preview.USER_NAME_JUSTIN
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
@ -119,6 +120,7 @@ private fun aDefaultInvitePeopleState(
isSearchActive: Boolean = false,
showSearchLoader: Boolean = false,
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
createRoomFromDmAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
suggestions: List<InvitableUser> = aMatrixUserList()
.take(5)
.map { user -> anInvitableUser(matrixUser = user, isSelected = user in selectedUsers) },
@ -132,6 +134,7 @@ private fun aDefaultInvitePeopleState(
isSearchActive = isSearchActive,
showSearchLoader = showSearchLoader,
sendInvitesAction = sendInvitesAction,
createRoomFromDmAction = createRoomFromDmAction,
suggestions = suggestions.toImmutableList(),
eventSink = {},
)

View file

@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@ -105,7 +106,7 @@ private fun InvitePeopleContentView(
}
InvitePeopleSearchBar(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.imePadding().fillMaxWidth(),
queryState = state.searchQuery,
showLoader = state.showSearchLoader,
selectedUsers = state.selectedUsers,

View file

@ -831,6 +831,54 @@ internal class DefaultInvitePeoplePresenterTest {
}
}
@Test
fun `present - inviting someone to a DM creates a new room`() = runTest {
val alice = aMatrixUser("@alice:example.com")
val matrixClient = FakeMatrixClient(
encryptionService = FakeEncryptionService(
getUserIdentityResult = lambdaRecorder { userId: UserId ->
Result.success(IdentityState.Pinned)
}
)
)
val presenter = createDefaultInvitePeoplePresenter(
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
matrixClient = matrixClient,
joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
initialRoomInfo = aRoomInfo(isDm = true),
getMembersResult = { Result.success(listOf(aRoomMember(userId = alice.userId, membership = RoomMembershipState.JOIN))) },
)
)
)
presenter.test {
val initialState = awaitItem()
skipItems(1)
// We want to add a new user to a DM
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(alice))
// And we send the invites
initialState.eventSink(InvitePeopleEvents.SendInvites)
skipItems(1)
awaitItemAsDefault().run {
assertThat(canInvite).isTrue()
assertThat(sendInvitesAction.isUninitialized()).isTrue()
// Inviting to a DM should trigger the creation of a new room
assertThat(createRoomFromDmAction.isLoading()).isTrue()
}
awaitItemAsDefault().run {
assertThat(sendInvitesAction.isUninitialized()).isTrue()
// Once the room is created, the action should be successful
assertThat(createRoomFromDmAction.isSuccess()).isTrue()
}
}
}
private suspend fun FakeUserRepository.emitStateWithUsers(
users: List<MatrixUser>,
isSearching: Boolean = false

View file

@ -40,7 +40,7 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun navigateToGlobalNotificationSettings()
fun navigateToDeveloperSettings()
fun navigateToRoom(roomId: RoomId, serverNames: List<String>)
fun navigateToRoom(roomId: RoomId, serverNames: List<String>, clearBackStack: Boolean = false)
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean)
}

View file

@ -263,7 +263,20 @@ class RoomDetailsFlowNode(
}
NavTarget.InviteMembers -> {
createNode<RoomInviteMembersNode>(buildContext)
val callback = object : RoomInviteMembersNode.Callback {
override fun openCreatedRoom(roomId: RoomId) {
navigateUp()
room.roomCoroutineScope.launch {
callback.navigateToRoom(
roomId = roomId,
serverNames = emptyList(),
// Remove the invite screen from the backstack to avoid navigating back to it after the new room has been created
clearBackStack = true,
)
}
}
}
createNode<RoomInviteMembersNode>(buildContext, plugins = listOf(callback))
}
is NavTarget.RoomNotificationSettings -> {

View file

@ -180,6 +180,7 @@ fun aDmRoomDetailsState(
roomName = roomName,
isPublic = false,
isEncrypted = isEncrypted,
canInvite = true,
roomType = RoomDetailsType.Dm(otherMember = aDmRoomMember(isIgnored = isDmMemberIgnored)),
roomMemberDetailsState = aUserProfileState(
isBlocked = AsyncData.Success(isDmMemberIgnored),

View file

@ -208,8 +208,15 @@ fun RoomDetailsView(
onClick = onSecurityAndPrivacyClick
)
}
}
state.roomMemberDetailsState?.let { dmMemberDetails ->
state.roomMemberDetailsState?.let { dmMemberDetails ->
if (state.canInvite) {
PreferenceCategory {
InviteItem(onClick = invitePeople)
}
}
PreferenceCategory {
ProfileItem(
verificationState = dmMemberDetails.verificationState,
onClick = { onProfileClick(dmMemberDetails.userId) }
@ -374,14 +381,14 @@ private fun MainActionsSection(
onClick = { onCall(CallIntent.VIDEO) },
)
}
if (state.canInvite && state.roomType !is RoomDetailsType.Dm) {
MainActionButton(
title = stringResource(CommonStrings.action_invite),
imageVector = CompoundIcons.UserAdd(),
onClick = onInvitePeople,
)
}
if (state.roomType is RoomDetailsType.Room) {
if (state.canInvite) {
MainActionButton(
title = stringResource(CommonStrings.action_invite),
imageVector = CompoundIcons.UserAdd(),
onClick = onInvitePeople,
)
}
// Share CTA should be hidden for DMs
MainActionButton(
title = stringResource(CommonStrings.action_share),
@ -693,6 +700,17 @@ private fun MembersItem(
)
}
@Composable
private fun InviteItem(
onClick: () -> Unit,
) {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_details_invite_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())),
onClick = onClick,
)
}
@Composable
private fun PinnedMessagesItem(
pinnedMessagesCount: Int?,

View file

@ -11,6 +11,7 @@ package io.element.android.features.roomdetails.impl.invite
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@ -19,10 +20,16 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.features.invitepeople.api.InvitePeoplePresenter
import io.element.android.features.invitepeople.api.InvitePeopleRenderer
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(RoomScope::class)
@ -35,6 +42,10 @@ class RoomInviteMembersNode(
room: JoinedRoom,
invitePeoplePresenterFactory: InvitePeoplePresenter.Factory,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun openCreatedRoom(roomId: RoomId)
}
init {
lifecycle.subscribe(
onResume = {
@ -48,6 +59,8 @@ class RoomInviteMembersNode(
roomId = room.roomId,
)
private val callback = plugins.callback<Callback>()
@Composable
override fun View(modifier: Modifier) {
val state = invitePeoplePresenter.present()
@ -59,6 +72,19 @@ class RoomInviteMembersNode(
}
}
AsyncActionView(
async = state.createRoomFromDmAction,
onSuccess = { roomId ->
callback.openCreatedRoom(roomId)
},
progressDialog = {
ProgressDialog(text = stringResource(CommonStrings.common_creating_room))
},
onErrorDismiss = {
state.eventSink(InvitePeopleEvents.ClearError)
}
)
RoomInviteMembersView(
state = state,
modifier = modifier,

View file

@ -70,7 +70,7 @@ class DefaultRoomDetailsEntryPointTest {
val callback = object : RoomDetailsEntryPoint.Callback {
override fun navigateToGlobalNotificationSettings() = lambdaError()
override fun navigateToDeveloperSettings() = lambdaError()
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) = lambdaError()
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>, clearBackStack: Boolean) = lambdaError()
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
override fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()
}

View file

@ -13,6 +13,8 @@ package io.element.android.features.roomdetails.impl
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
@ -339,6 +341,25 @@ class RoomDetailsViewTest {
clickOn(R.string.screen_room_details_profile_row_title)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `click on invite invokes the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
setRoomDetailView(
state = aRoomDetailsState(
eventSink = EventsRecorder(expectEvents = false),
roomType = RoomDetailsType.Dm(
aDmRoomMember(userId = UserId("@other:local.org")),
),
roomMemberDetailsState = aUserProfileState(userId = A_USER_ID),
canInvite = true,
),
invitePeople = callback,
)
onAllNodesWithText(activity!!.getString(R.string.screen_room_details_invite_title)).onLast().performClick()
}
}
}
private fun AndroidComposeUiTest<ComponentActivity>.setRoomDetailView(