Hide space children you have left that can only be joined by invitation

After leaving a room listed in a space, the room stayed in the child list with
a Join button, even though it can only be entered with a new invitation. It
disappeared only after leaving and reopening the space, because the server
omits it from the refetched hierarchy.

Apply the same rule locally in the presenter. The filter keys on the LEFT
membership combined with an invite join rule rather than on the derived
visibility, which collapses knock and unknown join rules into private and
would wrongly remove the Join button from knockable rooms.
This commit is contained in:
hayaksi1
2026-08-09 15:09:34 +03:00
parent 044737a3a7
commit 1299b5c897
2 changed files with 84 additions and 3 deletions
@@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
@@ -117,12 +118,14 @@ class SpacePresenter(
val filteredChildren by remember {
derivedStateOf {
val notRemoved = children.filterNot { it.roomId in removedRoomIds }
val visibleChildren = children
.filterNot { it.roomId in removedRoomIds }
.filterNot { it.cannotBeRejoined() }
if (isManageMode) {
// In manage mode, only show rooms (not spaces)
notRemoved.filter { !it.isSpace }.toImmutableList()
visibleChildren.filter { !it.isSpace }.toImmutableList()
} else {
notRemoved.toImmutableList()
visibleChildren.toImmutableList()
}
}
}
@@ -254,3 +257,7 @@ class SpacePresenter(
}
}
}
private fun SpaceRoom.cannotBeRejoined(): Boolean {
return state == CurrentUserMembership.LEFT && joinRule == JoinRule.Invite
}
@@ -30,11 +30,13 @@ import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID_3
import io.element.android.libraries.matrix.test.A_ROOM_ID_4
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
@@ -537,6 +539,78 @@ class SpacePresenterTest {
}
}
@Test
fun `present - left children which can only be joined by invitation are filtered out`() = runTest {
val leftInviteOnlyRoom = aSpaceRoom(
roomId = A_ROOM_ID,
roomType = RoomType.Room,
state = CurrentUserMembership.LEFT,
joinRule = JoinRule.Invite,
)
val leftPublicRoom = aSpaceRoom(
roomId = A_ROOM_ID_2,
roomType = RoomType.Room,
state = CurrentUserMembership.LEFT,
joinRule = JoinRule.Public,
)
val leftKnockRoom = aSpaceRoom(
roomId = A_ROOM_ID_3,
roomType = RoomType.Room,
state = CurrentUserMembership.LEFT,
joinRule = JoinRule.Knock,
)
val leftRoomWithUnknownJoinRule = aSpaceRoom(
roomId = A_ROOM_ID_4,
roomType = RoomType.Room,
state = CurrentUserMembership.LEFT,
joinRule = null,
)
val fakeSpaceRoomList = FakeSpaceRoomList(
initialSpaceRoomsValue = listOf(leftInviteOnlyRoom, leftPublicRoom, leftKnockRoom, leftRoomWithUnknownJoinRule),
paginateResult = { Result.success(Unit) },
)
val presenter = createSpacePresenter(spaceRoomList = fakeSpaceRoomList)
presenter.test {
awaitItem()
advanceUntilIdle()
val stateWithChildren = expectMostRecentItem()
assertThat(stateWithChildren.children).containsExactly(leftPublicRoom, leftKnockRoom, leftRoomWithUnknownJoinRule)
}
}
@Test
fun `present - children which can only be joined by invitation are kept when not left`() = runTest {
val joinedRoom = aSpaceRoom(
roomId = A_ROOM_ID,
roomType = RoomType.Room,
state = CurrentUserMembership.JOINED,
joinRule = JoinRule.Invite,
)
val invitedRoom = aSpaceRoom(
roomId = A_ROOM_ID_2,
roomType = RoomType.Room,
state = CurrentUserMembership.INVITED,
joinRule = JoinRule.Invite,
)
val roomWithUnknownMembership = aSpaceRoom(
roomId = A_ROOM_ID_3,
roomType = RoomType.Room,
state = null,
joinRule = JoinRule.Invite,
)
val fakeSpaceRoomList = FakeSpaceRoomList(
initialSpaceRoomsValue = listOf(joinedRoom, invitedRoom, roomWithUnknownMembership),
paginateResult = { Result.success(Unit) },
)
val presenter = createSpacePresenter(spaceRoomList = fakeSpaceRoomList)
presenter.test {
awaitItem()
advanceUntilIdle()
val stateWithChildren = expectMostRecentItem()
assertThat(stateWithChildren.children).containsExactly(joinedRoom, invitedRoom, roomWithUnknownMembership)
}
}
@Test
fun `present - removed rooms persist after flow update on partial failure`() = runTest {
// On partial failure, successfully removed rooms should stay filtered even after flow updates