Browse Source

Merge pull request #3206 from element-hq/feature/bma/updateGrammar

Update grammar on Matrix Ids to be more spec compliant and render error instead of infinite loading in room member list screen
pull/3210/head
Benoit Marty 2 months ago committed by GitHub
parent
commit
0ddc306f01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 39
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt
  2. 18
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt
  3. 81
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt
  4. 130
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt
  5. 10
      features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTest.kt
  6. 59
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt
  7. 74
      libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatternsTest.kt
  8. 3
      tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_8_en.png
  9. 3
      tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_8_en.png

39
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt

@ -31,6 +31,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationEvents import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationEvents
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationPresenter import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationPresenter
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
@ -59,10 +60,10 @@ class RoomMemberListPresenter @AssistedInject constructor(
@Composable @Composable
override fun present(): RoomMemberListState { override fun present(): RoomMemberListState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var roomMembers by remember { mutableStateOf(RoomMembers.loading()) } var roomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading()) }
var searchQuery by rememberSaveable { mutableStateOf("") } var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults by remember { var searchResults by remember {
mutableStateOf<SearchBarResultState<RoomMembers>>(SearchBarResultState.Initial()) mutableStateOf<SearchBarResultState<AsyncData<RoomMembers>>>(SearchBarResultState.Initial())
} }
var isSearchActive by rememberSaveable { mutableStateOf(false) } var isSearchActive by rememberSaveable { mutableStateOf(false) }
@ -82,6 +83,12 @@ class RoomMemberListPresenter @AssistedInject constructor(
if (membersState is MatrixRoomMembersState.Unknown) { if (membersState is MatrixRoomMembersState.Unknown) {
return@LaunchedEffect return@LaunchedEffect
} }
val finalMembersState = membersState
if (finalMembersState is MatrixRoomMembersState.Error && finalMembersState.roomMembers().orEmpty().isEmpty()) {
// Cannot fetch members and no cached members, display the error
roomMembers = AsyncData.Failure(finalMembersState.failure)
return@LaunchedEffect
}
withContext(coroutineDispatchers.io) { withContext(coroutineDispatchers.io) {
val members = membersState.roomMembers().orEmpty().groupBy { it.membership } val members = membersState.roomMembers().orEmpty().groupBy { it.membership }
val info = room.roomInfoFlow.first() val info = room.roomInfoFlow.first()
@ -90,14 +97,18 @@ class RoomMemberListPresenter @AssistedInject constructor(
// This result will come from the timeline loading membership events and it'll be wrong. // This result will come from the timeline loading membership events and it'll be wrong.
return@withContext return@withContext
} }
roomMembers = RoomMembers( val result = RoomMembers(
invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(), invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList()) joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList())
.sortedWith(PowerLevelRoomMemberComparator()) .sortedWith(PowerLevelRoomMemberComparator())
.toImmutableList(), .toImmutableList(),
banned = members.getOrDefault(RoomMembershipState.BAN, emptyList()).sortedBy { it.userId.value }.toImmutableList(), banned = members.getOrDefault(RoomMembershipState.BAN, emptyList()).sortedBy { it.userId.value }.toImmutableList(),
isLoading = membersState is MatrixRoomMembersState.Pending,
) )
roomMembers = if (membersState is MatrixRoomMembersState.Pending) {
AsyncData.Loading(result)
} else {
AsyncData.Success(result)
}
} }
} }
@ -110,15 +121,19 @@ class RoomMemberListPresenter @AssistedInject constructor(
if (results.isEmpty()) { if (results.isEmpty()) {
SearchBarResultState.NoResultsFound() SearchBarResultState.NoResultsFound()
} else { } else {
val result = RoomMembers(
invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList())
.sortedWith(PowerLevelRoomMemberComparator())
.toImmutableList(),
banned = results.getOrDefault(RoomMembershipState.BAN, emptyList()).sortedBy { it.userId.value }.toImmutableList(),
)
SearchBarResultState.Results( SearchBarResultState.Results(
RoomMembers( if (membersState is MatrixRoomMembersState.Pending) {
invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(), AsyncData.Loading(result)
joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList()) } else {
.sortedWith(PowerLevelRoomMemberComparator()) AsyncData.Success(result)
.toImmutableList(), }
banned = results.getOrDefault(RoomMembershipState.BAN, emptyList()).sortedBy { it.userId.value }.toImmutableList(),
isLoading = membersState is MatrixRoomMembersState.Pending,
)
) )
} }
} }

18
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt

@ -17,15 +17,15 @@
package io.element.android.features.roomdetails.impl.members package io.element.android.features.roomdetails.impl.members
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
data class RoomMemberListState( data class RoomMemberListState(
val roomMembers: RoomMembers, val roomMembers: AsyncData<RoomMembers>,
val searchQuery: String, val searchQuery: String,
val searchResults: SearchBarResultState<RoomMembers>, val searchResults: SearchBarResultState<AsyncData<RoomMembers>>,
val isSearchActive: Boolean, val isSearchActive: Boolean,
val canInvite: Boolean, val canInvite: Boolean,
val moderationState: RoomMembersModerationState, val moderationState: RoomMembersModerationState,
@ -36,14 +36,4 @@ data class RoomMembers(
val invited: ImmutableList<RoomMember>, val invited: ImmutableList<RoomMember>,
val joined: ImmutableList<RoomMember>, val joined: ImmutableList<RoomMember>,
val banned: ImmutableList<RoomMember>, val banned: ImmutableList<RoomMember>,
val isLoading: Boolean, )
) {
companion object {
fun loading() = RoomMembers(
invited = persistentListOf(),
joined = persistentListOf(),
banned = persistentListOf(),
isLoading = true,
)
}
}

81
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt

@ -19,6 +19,7 @@ package io.element.android.features.roomdetails.impl.members
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState
import io.element.android.features.roomdetails.impl.members.moderation.aRoomMembersModerationState import io.element.android.features.roomdetails.impl.members.moderation.aRoomMembersModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
@ -29,14 +30,15 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
override val values: Sequence<RoomMemberListState> override val values: Sequence<RoomMemberListState>
get() = sequenceOf( get() = sequenceOf(
aRoomMemberListState( aRoomMemberListState(
roomMembers = RoomMembers( roomMembers = AsyncData.Success(
invited = persistentListOf(aVictor(), aWalter()), RoomMembers(
joined = persistentListOf(anAlice(), aBob(), aWalter()), invited = persistentListOf(aVictor(), aWalter()),
banned = persistentListOf(), joined = persistentListOf(anAlice(), aBob(), aWalter()),
isLoading = false, banned = persistentListOf(),
)
) )
), ),
aRoomMemberListState(roomMembers = RoomMembers.loading()), aRoomMemberListState(roomMembers = AsyncData.Loading()),
aRoomMemberListState().copy(canInvite = true), aRoomMemberListState().copy(canInvite = true),
aRoomMemberListState().copy(isSearchActive = false), aRoomMemberListState().copy(isSearchActive = false),
aRoomMemberListState().copy(isSearchActive = true), aRoomMemberListState().copy(isSearchActive = true),
@ -45,11 +47,12 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
isSearchActive = true, isSearchActive = true,
searchQuery = "@someone:matrix.org", searchQuery = "@someone:matrix.org",
searchResults = SearchBarResultState.Results( searchResults = SearchBarResultState.Results(
RoomMembers( AsyncData.Success(
invited = persistentListOf(aVictor()), RoomMembers(
joined = persistentListOf(anAlice()), invited = persistentListOf(aVictor()),
banned = persistentListOf(), joined = persistentListOf(anAlice()),
isLoading = false, banned = persistentListOf(),
)
) )
), ),
), ),
@ -58,6 +61,9 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
searchQuery = "something-with-no-results", searchQuery = "something-with-no-results",
searchResults = SearchBarResultState.NoResultsFound() searchResults = SearchBarResultState.NoResultsFound()
), ),
aRoomMemberListState(
roomMembers = AsyncData.Failure(Exception("Error details")),
),
) )
} }
@ -65,37 +71,40 @@ internal class RoomMemberListStateBannedProvider : PreviewParameterProvider<Room
override val values: Sequence<RoomMemberListState> override val values: Sequence<RoomMemberListState>
get() = sequenceOf( get() = sequenceOf(
aRoomMemberListState( aRoomMemberListState(
roomMembers = RoomMembers( roomMembers = AsyncData.Success(
invited = persistentListOf(), RoomMembers(
joined = persistentListOf(), invited = persistentListOf(),
banned = persistentListOf( joined = persistentListOf(),
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice"), banned = persistentListOf(
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob"), aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice"),
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie"), aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob"),
), aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie"),
isLoading = false, ),
)
), ),
moderationState = aRoomMembersModerationState(canDisplayBannedUsers = true), moderationState = aRoomMembersModerationState(canDisplayBannedUsers = true),
), ),
aRoomMemberListState( aRoomMemberListState(
roomMembers = RoomMembers( roomMembers = AsyncData.Loading(
invited = persistentListOf(), RoomMembers(
joined = persistentListOf(), invited = persistentListOf(),
banned = persistentListOf( joined = persistentListOf(),
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice"), banned = persistentListOf(
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob"), aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice"),
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie"), aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob"),
), aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie"),
isLoading = true, ),
)
), ),
moderationState = aRoomMembersModerationState(canDisplayBannedUsers = true), moderationState = aRoomMembersModerationState(canDisplayBannedUsers = true),
), ),
aRoomMemberListState( aRoomMemberListState(
roomMembers = RoomMembers( roomMembers = AsyncData.Success(
invited = persistentListOf(), RoomMembers(
joined = persistentListOf(), invited = persistentListOf(),
banned = persistentListOf(), joined = persistentListOf(),
isLoading = false, banned = persistentListOf(),
)
), ),
moderationState = aRoomMembersModerationState(canDisplayBannedUsers = true), moderationState = aRoomMembersModerationState(canDisplayBannedUsers = true),
) )
@ -103,8 +112,8 @@ internal class RoomMemberListStateBannedProvider : PreviewParameterProvider<Room
} }
internal fun aRoomMemberListState( internal fun aRoomMemberListState(
roomMembers: RoomMembers = RoomMembers.loading(), roomMembers: AsyncData<RoomMembers> = AsyncData.Loading(),
searchResults: SearchBarResultState<RoomMembers> = SearchBarResultState.Initial(), searchResults: SearchBarResultState<AsyncData<RoomMembers>> = SearchBarResultState.Initial(),
moderationState: RoomMembersModerationState = aRoomMembersModerationState(), moderationState: RoomMembersModerationState = aRoomMembersModerationState(),
) = RoomMemberListState( ) = RoomMemberListState(
roomMembers = roomMembers, roomMembers = roomMembers,

130
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt

@ -53,6 +53,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.roomdetails.impl.R import io.element.android.features.roomdetails.impl.R
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationView import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationView
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -128,7 +129,6 @@ fun RoomMemberListView(
if (!state.isSearchActive) { if (!state.isSearchActive) {
RoomMemberList( RoomMemberList(
isLoading = state.roomMembers.isLoading,
roomMembers = state.roomMembers, roomMembers = state.roomMembers,
showMembersCount = true, showMembersCount = true,
canDisplayBannedUsersControls = state.moderationState.canDisplayBannedUsers, canDisplayBannedUsersControls = state.moderationState.canDisplayBannedUsers,
@ -149,8 +149,7 @@ fun RoomMemberListView(
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
private fun RoomMemberList( private fun RoomMemberList(
isLoading: Boolean, roomMembers: AsyncData<RoomMembers>,
roomMembers: RoomMembers,
showMembersCount: Boolean, showMembersCount: Boolean,
selectedSection: SelectedSection, selectedSection: SelectedSection,
onSelectedSectionChange: (SelectedSection) -> Unit, onSelectedSectionChange: (SelectedSection) -> Unit,
@ -183,7 +182,7 @@ private fun RoomMemberList(
} }
} }
AnimatedVisibility( AnimatedVisibility(
visible = isLoading, visible = roomMembers.isLoading(),
enter = fadeIn() + expandVertically(), enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(), exit = fadeOut() + shrinkVertically(),
) { ) {
@ -191,47 +190,72 @@ private fun RoomMemberList(
} }
} }
} }
when (selectedSection) { when (roomMembers) {
SelectedSection.MEMBERS -> { is AsyncData.Failure -> failureItem(roomMembers.error)
if (roomMembers.invited.isNotEmpty()) { is AsyncData.Loading,
roomMemberListSection( is AsyncData.Success -> memberItems(
headerText = { stringResource(id = R.string.screen_room_member_list_pending_header_title) }, roomMembers = roomMembers.dataOrNull() ?: return@LazyColumn,
members = roomMembers.invited, selectedSection = selectedSection,
onMemberSelected = { onSelectUser(it) } onSelectUser = onSelectUser,
) showMembersCount = showMembersCount,
} )
if (roomMembers.joined.isNotEmpty()) { AsyncData.Uninitialized -> Unit
roomMemberListSection( }
headerText = { }
if (showMembersCount) { }
val memberCount = roomMembers.joined.count()
pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount) private fun LazyListScope.memberItems(
} else { roomMembers: RoomMembers,
stringResource(id = R.string.screen_room_member_list_room_members_header_title) selectedSection: SelectedSection,
} onSelectUser: (RoomMember) -> Unit,
}, showMembersCount: Boolean,
members = roomMembers.joined, ) {
onMemberSelected = { onSelectUser(it) } when (selectedSection) {
) SelectedSection.MEMBERS -> {
} if (roomMembers.invited.isNotEmpty()) {
roomMemberListSection(
headerText = { stringResource(id = R.string.screen_room_member_list_pending_header_title) },
members = roomMembers.invited,
onMemberSelected = { onSelectUser(it) }
)
} }
SelectedSection.BANNED -> { // Banned users if (roomMembers.joined.isNotEmpty()) {
if (roomMembers.banned.isNotEmpty()) { roomMemberListSection(
roomMemberListSection( headerText = {
headerText = null, if (showMembersCount) {
members = roomMembers.banned, val memberCount = roomMembers.joined.count()
onMemberSelected = { onSelectUser(it) } pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount)
) } else {
} else { stringResource(id = R.string.screen_room_member_list_room_members_header_title)
item {
Box(Modifier.fillParentMaxSize().padding(horizontal = 16.dp)) {
Text(
modifier = Modifier.padding(bottom = 56.dp).align(Alignment.Center),
text = stringResource(id = R.string.screen_room_member_list_banned_empty),
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
} }
},
members = roomMembers.joined,
onMemberSelected = { onSelectUser(it) }
)
}
}
SelectedSection.BANNED -> { // Banned users
if (roomMembers.banned.isNotEmpty()) {
roomMemberListSection(
headerText = null,
members = roomMembers.banned,
onMemberSelected = { onSelectUser(it) }
)
} else {
item {
Box(
Modifier
.fillParentMaxSize()
.padding(horizontal = 16.dp)
) {
Text(
modifier = Modifier
.padding(bottom = 56.dp)
.align(Alignment.Center),
text = stringResource(id = R.string.screen_room_member_list_banned_empty),
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
} }
} }
} }
@ -239,9 +263,22 @@ private fun RoomMemberList(
} }
} }
private fun LazyListScope.failureItem(failure: Throwable) {
item {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 32.dp),
text = stringResource(id = CommonStrings.error_unknown) + "\n\n" + failure.localizedMessage,
color = ElementTheme.colors.textCriticalPrimary,
textAlign = TextAlign.Center,
)
}
}
private fun LazyListScope.roomMemberListSection( private fun LazyListScope.roomMemberListSection(
headerText: @Composable (() -> String)?, headerText: @Composable (() -> String)?,
members: ImmutableList<RoomMember>, members: ImmutableList<RoomMember>?,
onMemberSelected: (RoomMember) -> Unit, onMemberSelected: (RoomMember) -> Unit,
) { ) {
headerText?.let { headerText?.let {
@ -254,7 +291,7 @@ private fun LazyListScope.roomMemberListSection(
) )
} }
} }
items(members) { matrixUser -> items(members.orEmpty()) { matrixUser ->
RoomMemberListItem( RoomMemberListItem(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
roomMember = matrixUser, roomMember = matrixUser,
@ -320,7 +357,7 @@ private fun RoomMemberListTopBar(
@Composable @Composable
private fun RoomMemberSearchBar( private fun RoomMemberSearchBar(
query: String, query: String,
state: SearchBarResultState<RoomMembers>, state: SearchBarResultState<AsyncData<RoomMembers>>,
active: Boolean, active: Boolean,
placeHolderTitle: String, placeHolderTitle: String,
onActiveChange: (Boolean) -> Unit, onActiveChange: (Boolean) -> Unit,
@ -339,7 +376,6 @@ private fun RoomMemberSearchBar(
resultState = state, resultState = state,
resultHandler = { results -> resultHandler = { results ->
RoomMemberList( RoomMemberList(
isLoading = false,
roomMembers = results, roomMembers = results,
showMembersCount = false, showMembersCount = false,
onSelectUser = { onSelectUser(it) }, onSelectUser = { onSelectUser(it) },

10
features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTest.kt

@ -62,7 +62,7 @@ class RoomMemberListPresenterTest {
}.test { }.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.roomMembers.isLoading).isTrue() assertThat(initialState.roomMembers.isLoading()).isTrue()
assertThat(initialState.searchQuery).isEmpty() assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java) assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(initialState.isSearchActive).isFalse() assertThat(initialState.isSearchActive).isFalse()
@ -70,9 +70,9 @@ class RoomMemberListPresenterTest {
// Skip item while the new members state is processed // Skip item while the new members state is processed
skipItems(1) skipItems(1)
val loadedMembersState = awaitItem() val loadedMembersState = awaitItem()
assertThat(loadedMembersState.roomMembers.isLoading).isFalse() assertThat(loadedMembersState.roomMembers.isLoading()).isFalse()
assertThat(loadedMembersState.roomMembers.invited).isEqualTo(listOf(aVictor(), aWalter())) assertThat(loadedMembersState.roomMembers.dataOrNull()?.invited).isEqualTo(listOf(aVictor(), aWalter()))
assertThat(loadedMembersState.roomMembers.joined).isNotEmpty() assertThat(loadedMembersState.roomMembers.dataOrNull()?.joined).isNotEmpty()
} }
} }
@ -126,7 +126,7 @@ class RoomMemberListPresenterTest {
assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("Alice") assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("Alice")
val searchSearchResultDelivered = awaitItem() val searchSearchResultDelivered = awaitItem()
assertThat(searchSearchResultDelivered.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) assertThat(searchSearchResultDelivered.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
assertThat((searchSearchResultDelivered.searchResults as SearchBarResultState.Results).results.joined.first().displayName) assertThat((searchSearchResultDelivered.searchResults as SearchBarResultState.Results).results.dataOrNull()!!.joined.first().displayName)
.isEqualTo("Alice") .isEqualTo("Alice")
} }
} }

59
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt

@ -27,32 +27,40 @@ object MatrixPatterns {
// Note: TLD is not mandatory (localhost, IP address...) // Note: TLD is not mandatory (localhost, IP address...)
private const val DOMAIN_REGEX = ":[A-Za-z0-9.-]+(:[0-9]{2,5})?" private const val DOMAIN_REGEX = ":[A-Za-z0-9.-]+(:[0-9]{2,5})?"
private const val BASE_64_ALPHABET = "[0-9A-Za-z/\\+=]+"
private const val BASE_64_URL_SAFE_ALPHABET = "[0-9A-Za-z/\\-_]+"
// regex pattern to find matrix user ids in a string. // regex pattern to find matrix user ids in a string.
// See https://matrix.org/docs/spec/appendices#historical-user-ids // See https://matrix.org/docs/spec/appendices#historical-user-ids
// Sadly, we need to relax the regex pattern a bit as there already exist some ids that don't match the spec. // Sadly, we need to relax the regex pattern a bit as there already exist some ids that don't match the spec.
private const val MATRIX_USER_IDENTIFIER_REGEX = "^@.*?$DOMAIN_REGEX$" // Note: local part can be empty
private val PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER = MATRIX_USER_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE) private const val MATRIX_USER_IDENTIFIER_REGEX = "^@\\S*?$DOMAIN_REGEX$"
private val PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER = MATRIX_USER_IDENTIFIER_REGEX.toRegex()
// regex pattern to find room ids in a string. // regex pattern to match room ids.
private const val MATRIX_ROOM_IDENTIFIER_REGEX = "![A-Z0-9.-]+$DOMAIN_REGEX" // Note: roomId can be arbitrary strings, including space and new line char
private val PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER = MATRIX_ROOM_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE) private const val MATRIX_ROOM_IDENTIFIER_REGEX = "^!.+$DOMAIN_REGEX$"
private val PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER = MATRIX_ROOM_IDENTIFIER_REGEX.toRegex(RegexOption.DOT_MATCHES_ALL)
// regex pattern to find room aliases in a string. // regex pattern to match room aliases.
private const val MATRIX_ROOM_ALIAS_REGEX = "#[A-Z0-9._%#@=+-]+$DOMAIN_REGEX" private const val MATRIX_ROOM_ALIAS_REGEX = "^#\\S+$DOMAIN_REGEX$"
private val PATTERN_CONTAIN_MATRIX_ALIAS = MATRIX_ROOM_ALIAS_REGEX.toRegex(RegexOption.IGNORE_CASE) private val PATTERN_CONTAIN_MATRIX_ALIAS = MATRIX_ROOM_ALIAS_REGEX.toRegex(RegexOption.IGNORE_CASE)
// regex pattern to find message ids in a string. // regex pattern to match event ids.
// Sadly, we need to relax the regex pattern a bit as there already exist some ids that don't match the spec. // Sadly, we need to relax the regex pattern a bit as there already exist some ids that don't match the spec.
// v1 and v2: arbitrary string + domain
private const val MATRIX_EVENT_IDENTIFIER_REGEX = "^\\$.+$DOMAIN_REGEX$" private const val MATRIX_EVENT_IDENTIFIER_REGEX = "^\\$.+$DOMAIN_REGEX$"
private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER = MATRIX_EVENT_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE) private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER = MATRIX_EVENT_IDENTIFIER_REGEX.toRegex()
// v3: base64
private const val MATRIX_EVENT_IDENTIFIER_V3_REGEX = "\\$$BASE_64_ALPHABET"
private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V3 = MATRIX_EVENT_IDENTIFIER_V3_REGEX.toRegex()
// regex pattern to find message ids in a string. // v4: url-safe base64
private const val MATRIX_EVENT_IDENTIFIER_V3_REGEX = "\\$[A-Z0-9/+]+" private const val MATRIX_EVENT_IDENTIFIER_V4_REGEX = "\\$$BASE_64_URL_SAFE_ALPHABET"
private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V3 = MATRIX_EVENT_IDENTIFIER_V3_REGEX.toRegex(RegexOption.IGNORE_CASE) private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4 = MATRIX_EVENT_IDENTIFIER_V4_REGEX.toRegex()
// Ref: https://matrix.org/docs/spec/rooms/v4#event-ids private const val MAX_IDENTIFIER_LENGTH = 255
private const val MATRIX_EVENT_IDENTIFIER_V4_REGEX = "\\$[A-Z0-9\\-_]+"
private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4 = MATRIX_EVENT_IDENTIFIER_V4_REGEX.toRegex(RegexOption.IGNORE_CASE)
/** /**
* Tells if a string is a valid user Id. * Tells if a string is a valid user Id.
@ -61,7 +69,9 @@ object MatrixPatterns {
* @return true if the string is a valid user id * @return true if the string is a valid user id
*/ */
fun isUserId(str: String?): Boolean { fun isUserId(str: String?): Boolean {
return str != null && str matches PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER return str != null &&
str.length <= MAX_IDENTIFIER_LENGTH &&
str matches PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER
} }
/** /**
@ -79,7 +89,9 @@ object MatrixPatterns {
* @return true if the string is a valid room Id * @return true if the string is a valid room Id
*/ */
fun isRoomId(str: String?): Boolean { fun isRoomId(str: String?): Boolean {
return str != null && str matches PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER return str != null &&
str.length <= MAX_IDENTIFIER_LENGTH &&
str matches PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER
} }
/** /**
@ -89,7 +101,9 @@ object MatrixPatterns {
* @return true if the string is a valid room alias. * @return true if the string is a valid room alias.
*/ */
fun isRoomAlias(str: String?): Boolean { fun isRoomAlias(str: String?): Boolean {
return str != null && str matches PATTERN_CONTAIN_MATRIX_ALIAS return str != null &&
str.length <= MAX_IDENTIFIER_LENGTH &&
str matches PATTERN_CONTAIN_MATRIX_ALIAS
} }
/** /**
@ -100,9 +114,10 @@ object MatrixPatterns {
*/ */
fun isEventId(str: String?): Boolean { fun isEventId(str: String?): Boolean {
return str != null && return str != null &&
(str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER || str.length <= MAX_IDENTIFIER_LENGTH &&
(str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4 ||
str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V3 || str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V3 ||
str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4) str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER)
} }
/** /**
@ -118,8 +133,8 @@ object MatrixPatterns {
* Note not all cases are implemented. * Note not all cases are implemented.
*/ */
fun findPatterns(text: CharSequence, permalinkParser: PermalinkParser): List<MatrixPatternResult> { fun findPatterns(text: CharSequence, permalinkParser: PermalinkParser): List<MatrixPatternResult> {
val rawTextMatches = "\\S+?$DOMAIN_REGEX".toRegex(RegexOption.IGNORE_CASE).findAll(text) val rawTextMatches = "\\S+$DOMAIN_REGEX".toRegex(RegexOption.IGNORE_CASE).findAll(text)
val urlMatches = "\\[\\S+?\\]\\((\\S+?)\\)".toRegex(RegexOption.IGNORE_CASE).findAll(text) val urlMatches = "\\[\\S+\\]\\((\\S+)\\)".toRegex(RegexOption.IGNORE_CASE).findAll(text)
val atRoomMatches = Regex("@room").findAll(text) val atRoomMatches = Regex("@room").findAll(text)
return buildList { return buildList {
for (match in rawTextMatches) { for (match in rawTextMatches) {

74
libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatternsTest.kt

@ -23,6 +23,8 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import org.junit.Test import org.junit.Test
class MatrixPatternsTest { class MatrixPatternsTest {
private val longLocalPart = "a".repeat(255 - ":server.com".length - 1)
@Test @Test
fun `findPatterns - returns raw user ids`() { fun `findPatterns - returns raw user ids`() {
val text = "A @user:server.com and @user2:server.com" val text = "A @user:server.com and @user2:server.com"
@ -54,7 +56,7 @@ class MatrixPatternsTest {
} }
@Test @Test
fun `findPatterns - returns raw room event ids`() { fun `findPatterns - returns raw event ids`() {
val text = "A \$event:server.com and \$event2:server.com" val text = "A \$event:server.com and \$event2:server.com"
val patterns = MatrixPatterns.findPatterns(text, aPermalinkParser()) val patterns = MatrixPatterns.findPatterns(text, aPermalinkParser())
assertThat(patterns).containsExactly( assertThat(patterns).containsExactly(
@ -90,6 +92,76 @@ class MatrixPatternsTest {
assertThat(patterns).containsExactly(MatrixPatternResult(MatrixPatternType.ROOM_ALIAS, "#room:server.com", 2, 46)) assertThat(patterns).containsExactly(MatrixPatternResult(MatrixPatternType.ROOM_ALIAS, "#room:server.com", 2, 46))
} }
@Test
fun `test isRoomId`() {
assertThat(MatrixPatterns.isRoomId(null)).isFalse()
assertThat(MatrixPatterns.isRoomId("")).isFalse()
assertThat(MatrixPatterns.isRoomId("not a room id")).isFalse()
assertThat(MatrixPatterns.isRoomId(" !room:server.com")).isFalse()
assertThat(MatrixPatterns.isRoomId("!room:server.com ")).isFalse()
assertThat(MatrixPatterns.isRoomId("@room:server.com")).isFalse()
assertThat(MatrixPatterns.isRoomId("#room:server.com")).isFalse()
assertThat(MatrixPatterns.isRoomId("\$room:server.com")).isFalse()
assertThat(MatrixPatterns.isRoomId("!${longLocalPart}a:server.com")).isFalse()
assertThat(MatrixPatterns.isRoomId("!room:server.com")).isTrue()
assertThat(MatrixPatterns.isRoomId("!$longLocalPart:server.com")).isTrue()
assertThat(MatrixPatterns.isRoomId("!#test/room\nversion <u>11</u>, with @🐈:maunium.net")).isTrue()
}
@Test
fun `test isRoomAlias`() {
assertThat(MatrixPatterns.isRoomAlias(null)).isFalse()
assertThat(MatrixPatterns.isRoomAlias("")).isFalse()
assertThat(MatrixPatterns.isRoomAlias("not a room alias")).isFalse()
assertThat(MatrixPatterns.isRoomAlias(" #room:server.com")).isFalse()
assertThat(MatrixPatterns.isRoomAlias("#room:server.com ")).isFalse()
assertThat(MatrixPatterns.isRoomAlias("@room:server.com")).isFalse()
assertThat(MatrixPatterns.isRoomAlias("!room:server.com")).isFalse()
assertThat(MatrixPatterns.isRoomAlias("\$room:server.com")).isFalse()
assertThat(MatrixPatterns.isRoomAlias("#${longLocalPart}a:server.com")).isFalse()
assertThat(MatrixPatterns.isRoomAlias("#room:server.com")).isTrue()
assertThat(MatrixPatterns.isRoomAlias("#nico's-stickers:neko.dev")).isTrue()
assertThat(MatrixPatterns.isRoomAlias("#$longLocalPart:server.com")).isTrue()
}
@Test
fun `test isEventId`() {
assertThat(MatrixPatterns.isEventId(null)).isFalse()
assertThat(MatrixPatterns.isEventId("")).isFalse()
assertThat(MatrixPatterns.isEventId("not an event id")).isFalse()
assertThat(MatrixPatterns.isEventId(" \$event:server.com")).isFalse()
assertThat(MatrixPatterns.isEventId("\$event:server.com ")).isFalse()
assertThat(MatrixPatterns.isEventId("@event:server.com")).isFalse()
assertThat(MatrixPatterns.isEventId("!event:server.com")).isFalse()
assertThat(MatrixPatterns.isEventId("#event:server.com")).isFalse()
assertThat(MatrixPatterns.isEventId("$${longLocalPart}a:server.com")).isFalse()
assertThat(MatrixPatterns.isEventId("\$" + "a".repeat(255))).isFalse()
assertThat(MatrixPatterns.isEventId("\$event:server.com")).isTrue()
assertThat(MatrixPatterns.isEventId("$$longLocalPart:server.com")).isTrue()
assertThat(MatrixPatterns.isEventId("\$9BozuV4TBw6rfRW3rMEgZ5v-jNk1D6FA8Hd1OsWqT9k")).isTrue()
assertThat(MatrixPatterns.isEventId("\$" + "a".repeat(254))).isTrue()
}
@Test
fun `test isUserId`() {
assertThat(MatrixPatterns.isUserId(null)).isFalse()
assertThat(MatrixPatterns.isUserId("")).isFalse()
assertThat(MatrixPatterns.isUserId("not a user id")).isFalse()
assertThat(MatrixPatterns.isUserId(" @user:server.com")).isFalse()
assertThat(MatrixPatterns.isUserId("@user:server.com ")).isFalse()
assertThat(MatrixPatterns.isUserId("!user:server.com")).isFalse()
assertThat(MatrixPatterns.isUserId("#user:server.com")).isFalse()
assertThat(MatrixPatterns.isUserId("\$user:server.com")).isFalse()
assertThat(MatrixPatterns.isUserId("@${longLocalPart}a:server.com")).isFalse()
assertThat(MatrixPatterns.isUserId("@user:server.com")).isTrue()
assertThat(MatrixPatterns.isUserId("@:server.com")).isTrue()
assertThat(MatrixPatterns.isUserId("@$longLocalPart:server.com")).isTrue()
}
private fun aPermalinkParser(block: (String) -> PermalinkData = { PermalinkData.FallbackLink(Uri.EMPTY) }) = object : PermalinkParser { private fun aPermalinkParser(block: (String) -> PermalinkData = { PermalinkData.FallbackLink(Uri.EMPTY) }) = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData { override fun parse(uriString: String): PermalinkData {
return block(uriString) return block(uriString)

3
tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_8_en.png

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cd04abc2eec057ef2dbd02e06c3f0ecd8e66fed3f760f293af4bb86c91e031f6
size 18284

3
tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_8_en.png

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:93cd01b7fe046a90b49a20311941bb3a07e8d2bd71eb9335343f2c15713ce73e
size 17222
Loading…
Cancel
Save