Merge branch 'develop' into feature/fga/room_list_filters

This commit is contained in:
ganfra 2024-02-19 18:00:01 +01:00
commit c2e969c592
94 changed files with 1437 additions and 451 deletions

View file

@ -47,7 +47,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
- uses: mobile-dev-inc/action-maestro-cloud@v1.8.0 - uses: mobile-dev-inc/action-maestro-cloud@v1.8.1
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch' if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with: with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }} api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}

View file

@ -0,0 +1 @@
Improve how Talkback works with the timeline. Sadly, it's still not 100% working, but there is some issue with the `LazyColumn` using `reverseLayout` that only Google can fix.

3
changelog.d/2256.feature Normal file
View file

@ -0,0 +1,3 @@
Add moderation to rooms:
- Sort member in room member list by powerlevel, display their roles.

View file

@ -27,7 +27,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
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.RoomMembershipState
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
@ -38,6 +37,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
@ -431,7 +431,7 @@ class InviteListPresenterTests {
avatarUrl = null, avatarUrl = null,
isDirect = false, isDirect = false,
lastMessage = null, lastMessage = null,
inviter = RoomMember( inviter = aRoomMember(
userId = A_USER_ID, userId = A_USER_ID,
displayName = A_USER_NAME, displayName = A_USER_NAME,
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
@ -458,7 +458,7 @@ class InviteListPresenterTests {
avatarUrl = null, avatarUrl = null,
isDirect = true, isDirect = true,
lastMessage = null, lastMessage = null,
inviter = RoomMember( inviter = aRoomMember(
userId = A_USER_ID, userId = A_USER_ID,
displayName = A_USER_NAME, displayName = A_USER_NAME,
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,

View file

@ -22,6 +22,11 @@ plugins {
android { android {
namespace = "io.element.android.features.location.impl" namespace = "io.element.android.features.location.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
} }
anvil { anvil {
@ -51,11 +56,14 @@ dependencies {
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime) testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(libs.test.truth)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.testtags)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test) testImplementation(projects.features.messages.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
} }

View file

@ -24,78 +24,47 @@ private const val APP_NAME = "ApplicationName"
class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> { class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
override val values: Sequence<ShowLocationState> override val values: Sequence<ShowLocationState>
get() = sequenceOf( get() = sequenceOf(
ShowLocationState( aShowLocationState(),
ShowLocationState.Dialog.None, aShowLocationState(
Location(1.23, 2.34, 4f), permissionDialog = ShowLocationState.Dialog.PermissionDenied,
description = null,
hasLocationPermission = false,
isTrackMyLocation = false,
appName = APP_NAME,
eventSink = {},
), ),
ShowLocationState( aShowLocationState(
ShowLocationState.Dialog.PermissionDenied, permissionDialog = ShowLocationState.Dialog.PermissionRationale,
Location(1.23, 2.34, 4f),
description = null,
hasLocationPermission = false,
isTrackMyLocation = false,
appName = APP_NAME,
eventSink = {},
), ),
ShowLocationState( aShowLocationState(
ShowLocationState.Dialog.PermissionRationale,
Location(1.23, 2.34, 4f),
description = null,
hasLocationPermission = false,
isTrackMyLocation = false,
appName = APP_NAME,
eventSink = {},
),
ShowLocationState(
ShowLocationState.Dialog.None,
Location(1.23, 2.34, 4f),
description = null,
hasLocationPermission = true, hasLocationPermission = true,
isTrackMyLocation = false,
appName = APP_NAME,
eventSink = {},
), ),
ShowLocationState( aShowLocationState(
ShowLocationState.Dialog.None,
Location(1.23, 2.34, 4f),
description = null,
hasLocationPermission = true, hasLocationPermission = true,
isTrackMyLocation = true, isTrackMyLocation = true,
appName = APP_NAME,
eventSink = {},
), ),
ShowLocationState( aShowLocationState(
ShowLocationState.Dialog.None,
Location(1.23, 2.34, 4f),
description = "My favourite place!", description = "My favourite place!",
hasLocationPermission = false,
isTrackMyLocation = false,
appName = APP_NAME,
eventSink = {},
), ),
ShowLocationState( aShowLocationState(
ShowLocationState.Dialog.None,
Location(1.23, 2.34, 4f),
description = "For some reason I decided to to write a small essay that wraps at just two lines!", description = "For some reason I decided to to write a small essay that wraps at just two lines!",
hasLocationPermission = false,
isTrackMyLocation = false,
appName = APP_NAME,
eventSink = {},
), ),
ShowLocationState( aShowLocationState(
ShowLocationState.Dialog.None,
Location(1.23, 2.34, 4f),
description = "For some reason I decided to write a small essay in the location description. " + description = "For some reason I decided to write a small essay in the location description. " +
"It is so long that it will wrap onto more than two lines!", "It is so long that it will wrap onto more than two lines!",
hasLocationPermission = false,
isTrackMyLocation = false,
appName = APP_NAME,
eventSink = {},
), ),
) )
} }
fun aShowLocationState(
permissionDialog: ShowLocationState.Dialog = ShowLocationState.Dialog.None,
location: Location = Location(1.23, 2.34, 4f),
description: String? = null,
hasLocationPermission: Boolean = false,
isTrackMyLocation: Boolean = false,
appName: String = APP_NAME,
eventSink: (ShowLocationEvents) -> Unit = {},
) = ShowLocationState(
permissionDialog = permissionDialog,
location = location,
description = description,
hasLocationPermission = hasLocationPermission,
isTrackMyLocation = isTrackMyLocation,
appName = appName,
eventSink = eventSink,
)

View file

@ -120,10 +120,14 @@ fun ShowLocationView(
) )
}, },
navigationIcon = { navigationIcon = {
BackButton(onClick = onBackPressed) BackButton(
onClick = onBackPressed,
)
}, },
actions = { actions = {
IconButton(onClick = { state.eventSink(ShowLocationEvents.Share) }) { IconButton(
onClick = { state.eventSink(ShowLocationEvents.Share) }
) {
Icon( Icon(
imageVector = CompoundIcons.ShareAndroid(), imageVector = CompoundIcons.ShareAndroid(),
contentDescription = stringResource(CommonStrings.action_share), contentDescription = stringResource(CommonStrings.action_share),

View file

@ -0,0 +1,156 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.show
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ShowLocationViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `test back action`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setShowLocationView(
state = aShowLocationState(
eventSink = eventsRecorder
),
onBackPressed = callback,
)
rule.pressBack()
}
}
@Test
fun `test share action`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
rule.setShowLocationView(
aShowLocationState(
eventSink = eventsRecorder
),
onBackPressed = EnsureNeverCalled(),
)
val shareContentDescription = rule.activity.getString(CommonStrings.action_share)
rule.onNodeWithContentDescription(shareContentDescription).performClick()
eventsRecorder.assertSingle(ShowLocationEvents.Share)
}
@Test
fun `test fab click`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
rule.setShowLocationView(
aShowLocationState(
eventSink = eventsRecorder
),
onBackPressed = EnsureNeverCalled(),
)
val shareContentDescription = rule.activity.getString(CommonStrings.action_share)
rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick()
eventsRecorder.assertSingle(ShowLocationEvents.TrackMyLocation(true))
}
@Test
fun `when permission denied is displayed user can open the settings`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
rule.setShowLocationView(
aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionDenied,
eventSink = eventsRecorder
),
onBackPressed = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShowLocationEvents.OpenAppSettings)
}
@Test
fun `when permission denied is displayed user can close the dialog`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
rule.setShowLocationView(
aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionDenied,
eventSink = eventsRecorder
),
onBackPressed = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog)
}
@Test
fun `when permission rationale is displayed user can request permissions`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
rule.setShowLocationView(
aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionRationale,
eventSink = eventsRecorder
),
onBackPressed = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShowLocationEvents.RequestPermissions)
}
@Test
fun `when permission rationale is displayed user can close the dialog`() {
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
rule.setShowLocationView(
aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionRationale,
eventSink = eventsRecorder
),
onBackPressed = EnsureNeverCalled(),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setShowLocationView(
state: ShowLocationState,
onBackPressed: () -> Unit = EnsureNeverCalled(),
) {
setContent {
// Simulate a LocalInspectionMode for MapboxMap
CompositionLocalProvider(LocalInspectionMode provides true) {
ShowLocationView(
state = state,
onBackPressed = onBackPressed,
)
}
}
}

View file

@ -152,6 +152,7 @@ internal fun MentionSuggestionsPickerView_Preview() {
powerLevel = 0L, powerLevel = 0L,
normalizedPowerLevel = 0L, normalizedPowerLevel = 0L,
isIgnored = false, isIgnored = false,
role = RoomMember.Role.USER,
) )
MentionSuggestionsPickerView( MentionSuggestionsPickerView(
roomId = RoomId("!room:matrix.org"), roomId = RoomId("!room:matrix.org"),

View file

@ -18,9 +18,11 @@
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import android.view.accessibility.AccessibilityManager
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -45,8 +47,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -64,7 +66,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.typing.TypingNotificationView import io.element.android.features.messages.impl.typing.TypingNotificationView
import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.libraries.designsystem.animation.alphaAnimation
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
@ -99,7 +100,13 @@ fun TimelineView(
state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex)) state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex))
} }
val context = LocalContext.current
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
// Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version
val useReverseLayout = remember {
val accessibilityManager = context.getSystemService(AccessibilityManager::class.java)
accessibilityManager.isTouchExplorationEnabled.not()
}
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")
fun inReplyToClicked(eventId: EventId) { fun inReplyToClicked(eventId: EventId) {
@ -107,67 +114,67 @@ fun TimelineView(
} }
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
val alpha by alphaAnimation(label = "alpha for timeline") AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) {
Box(modifier = modifier.alpha(alpha)) { LazyColumn(
LazyColumn( modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize(), state = lazyListState,
state = lazyListState, reverseLayout = useReverseLayout,
reverseLayout = true, contentPadding = PaddingValues(vertical = 8.dp),
contentPadding = PaddingValues(vertical = 8.dp), ) {
) { item {
item { TypingNotificationView(state = typingNotificationState)
TypingNotificationView(state = typingNotificationState) }
} items(
items( items = state.timelineItems,
items = state.timelineItems, contentType = { timelineItem -> timelineItem.contentType() },
contentType = { timelineItem -> timelineItem.contentType() }, key = { timelineItem -> timelineItem.identifier() },
key = { timelineItem -> timelineItem.identifier() }, ) { timelineItem ->
) { timelineItem -> TimelineItemRow(
TimelineItemRow( timelineItem = timelineItem,
timelineItem = timelineItem, timelineRoomInfo = state.timelineRoomInfo,
timelineRoomInfo = state.timelineRoomInfo, renderReadReceipts = state.renderReadReceipts,
renderReadReceipts = state.renderReadReceipts, isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true &&
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true && state.timelineItems.first().identifier() == timelineItem.identifier(),
state.timelineItems.first().identifier() == timelineItem.identifier(), highlightedItem = state.highlightedEventId?.value,
highlightedItem = state.highlightedEventId?.value, onClick = onMessageClicked,
onClick = onMessageClicked, onLongClick = onMessageLongClicked,
onLongClick = onMessageLongClicked, onUserDataClick = onUserDataClicked,
onUserDataClick = onUserDataClicked, inReplyToClick = ::inReplyToClicked,
inReplyToClick = ::inReplyToClicked, onReactionClick = onReactionClicked,
onReactionClick = onReactionClicked, onReactionLongClick = onReactionLongClicked,
onReactionLongClick = onReactionLongClicked, onMoreReactionsClick = onMoreReactionsClicked,
onMoreReactionsClick = onMoreReactionsClicked, onReadReceiptClick = onReadReceiptClick,
onReadReceiptClick = onReadReceiptClick, onTimestampClicked = onTimestampClicked,
onTimestampClicked = onTimestampClicked, sessionState = state.sessionState,
sessionState = state.sessionState, eventSink = state.eventSink,
eventSink = state.eventSink, onSwipeToReply = onSwipeToReply,
onSwipeToReply = onSwipeToReply, )
) }
} if (state.paginationState.hasMoreToLoadBackwards) {
if (state.paginationState.hasMoreToLoadBackwards) { // Do not use key parameter to avoid wrong positioning
// Do not use key parameter to avoid wrong positioning item(contentType = "TimelineLoadingMoreIndicator") {
item(contentType = "TimelineLoadingMoreIndicator") { TimelineLoadingMoreIndicator()
TimelineLoadingMoreIndicator() LaunchedEffect(Unit) {
LaunchedEffect(Unit) { onReachedLoadMore()
onReachedLoadMore() }
}
}
if (state.paginationState.beginningOfRoomReached && !state.timelineRoomInfo.isDirect) {
item(contentType = "BeginningOfRoomReached") {
TimelineItemRoomBeginningView(roomName = roomName)
} }
} }
} }
if (state.paginationState.beginningOfRoomReached && !state.timelineRoomInfo.isDirect) {
item(contentType = "BeginningOfRoomReached") {
TimelineItemRoomBeginningView(roomName = roomName)
}
}
}
TimelineScrollHelper( TimelineScrollHelper(
isTimelineEmpty = state.timelineItems.isEmpty(), isTimelineEmpty = state.timelineItems.isEmpty(),
lazyListState = lazyListState, lazyListState = lazyListState,
forceJumpToBottomVisibility = forceJumpToBottomVisibility, forceJumpToBottomVisibility = forceJumpToBottomVisibility,
newEventState = state.newEventState, newEventState = state.newEventState,
onScrollFinishedAt = ::onScrollFinishedAt onScrollFinishedAt = ::onScrollFinishedAt
) )
}
} }
} }

View file

@ -45,6 +45,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
@ -53,6 +54,11 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.invisibleToUser
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -107,6 +113,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
@ -256,6 +263,7 @@ private fun SwipeSensitivity(
} }
} }
@OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
private fun TimelineItemEventRowContent( private fun TimelineItemEventRowContent(
event: TimelineItem.Event, event: TimelineItem.Event,
@ -305,6 +313,11 @@ private fun TimelineItemEventRowContent(
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.zIndex(1f) .zIndex(1f)
.clickable(onClick = onUserDataClicked) .clickable(onClick = onUserDataClicked)
// This is redundant when using talkback
.clearAndSetSemantics {
invisibleToUser()
testTag = TestTags.timelineItemSenderInfo.value
}
) )
} }
@ -413,6 +426,7 @@ private fun MessageSenderInformation(
private fun MessageEventBubbleContent( private fun MessageEventBubbleContent(
event: TimelineItem.Event, event: TimelineItem.Event,
onMessageLongClick: () -> Unit, onMessageLongClick: () -> Unit,
@Suppress("UNUSED_PARAMETER")
inReplyToClick: () -> Unit, inReplyToClick: () -> Unit,
onTimestampClicked: () -> Unit, onTimestampClicked: () -> Unit,
onMentionClicked: (Mention) -> Unit, onMentionClicked: (Mention) -> Unit,
@ -445,6 +459,7 @@ private fun MessageEventBubbleContent(
text = stringResource(CommonStrings.common_thread), text = stringResource(CommonStrings.common_thread),
style = ElementTheme.typography.fontBodyXsRegular, style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
modifier = Modifier.clearAndSetSemantics { }
) )
} }
} }
@ -580,7 +595,8 @@ private fun MessageEventBubbleContent(
modifier = Modifier modifier = Modifier
.padding(top = topPadding, start = 8.dp, end = 8.dp) .padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(6.dp))
.clickable(enabled = true, onClick = inReplyToClick), // FIXME when a node is clickable, its contents won't be added to the semantics tree of its parent
// .clickable(enabled = true, onClick = inReplyToClick)
) )
} }
if (inReplyToDetails != null) { if (inReplyToDetails != null) {
@ -611,7 +627,9 @@ private fun MessageEventBubbleContent(
timestampPosition = timestampPosition, timestampPosition = timestampPosition,
inReplyToDetails = event.inReplyTo, inReplyToDetails = event.inReplyTo,
canShrinkContent = event.content is TimelineItemVoiceContent, canShrinkContent = event.content is TimelineItemVoiceContent,
modifier = bubbleModifier modifier = bubbleModifier.semantics(mergeDescendants = true) {
contentDescription = event.safeSenderName
}
) )
} }
@ -641,8 +659,12 @@ private fun ReplyToContent(
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
} }
val a11InReplyToText = stringResource(CommonStrings.common_in_reply_to, senderName)
Column(verticalArrangement = Arrangement.SpaceBetween) { Column(verticalArrangement = Arrangement.SpaceBetween) {
Text( Text(
modifier = Modifier.semantics {
contentDescription = a11InReplyToText
},
text = senderName, text = senderName,
style = ElementTheme.typography.fontBodySmMedium, style = ElementTheme.typography.fontBodySmMedium,
textAlign = TextAlign.Start, textAlign = TextAlign.Start,

View file

@ -18,6 +18,9 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
@ -25,15 +28,17 @@ import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun TimelineItemImageView( fun TimelineItemImageView(
content: TimelineItemImageContent, content: TimelineItemImageContent,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val description = stringResource(CommonStrings.common_image)
TimelineItemAspectRatioBox( TimelineItemAspectRatioBox(
aspectRatio = content.aspectRatio, aspectRatio = content.aspectRatio,
modifier = modifier, modifier = modifier.semantics { contentDescription = description },
) { ) {
BlurHashAsyncImage( BlurHashAsyncImage(
model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)), model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)),

View file

@ -23,6 +23,8 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
@ -48,7 +50,7 @@ fun TimelineItemTextView(
val formattedBody = content.formattedBody val formattedBody = content.formattedBody
val body = SpannableString(formattedBody ?: content.body) val body = SpannableString(formattedBody ?: content.body)
Box(modifier) { Box(modifier.semantics { contentDescription = body.toString() }) {
EditorStyledText( EditorStyledText(
text = body, text = body,
onLinkClickedListener = onLinkClicked, onLinkClickedListener = onLinkClicked,

View file

@ -27,6 +27,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
@ -42,9 +44,10 @@ fun TimelineItemVideoView(
content: TimelineItemVideoContent, content: TimelineItemVideoContent,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val description = stringResource(CommonStrings.common_image)
TimelineItemAspectRatioBox( TimelineItemAspectRatioBox(
aspectRatio = content.aspectRatio, aspectRatio = content.aspectRatio,
modifier = modifier, modifier = modifier.semantics { contentDescription = description },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
BlurHashAsyncImage( BlurHashAsyncImage(

View file

@ -103,5 +103,6 @@ private fun createDefaultRoomMemberForTyping(userId: UserId): RoomMember {
powerLevel = 0, powerLevel = 0,
normalizedPowerLevel = 0, normalizedPowerLevel = 0,
isIgnored = false, isIgnored = false,
role = RoomMember.Role.USER,
) )
} }

View file

@ -98,5 +98,6 @@ internal fun aTypingRoomMember(
powerLevel = 0, powerLevel = 0,
normalizedPowerLevel = 0, normalizedPowerLevel = 0,
isIgnored = false, isIgnored = false,
role = RoomMember.Role.USER,
) )
} }

View file

@ -49,6 +49,7 @@ import io.element.android.features.messages.impl.voicemessages.timeline.FakeReda
import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.test.actions.FakeEndPollAction import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
@ -98,6 +99,7 @@ import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -372,6 +374,22 @@ class MessagesPresenterTest {
} }
} }
@Test
fun `present - handle action end poll`() = runTest {
val endPollAction = FakeEndPollAction()
val presenter = createMessagesPresenter(endPollAction = endPollAction)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
endPollAction.verifyExecutionCount(0)
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.EndPoll, aMessageEvent(content = aTimelineItemPollContent())))
delay(1)
endPollAction.verifyExecutionCount(1)
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - handle action redact`() = runTest { fun `present - handle action redact`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
@ -683,6 +701,7 @@ class MessagesPresenterTest {
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
endPollAction: EndPollAction = FakeEndPollAction(),
): MessagesPresenter { ): MessagesPresenter {
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom) val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter) val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
@ -721,7 +740,7 @@ class MessagesPresenterTest {
encryptionService = FakeEncryptionService(), encryptionService = FakeEncryptionService(),
verificationService = FakeSessionVerificationService(), verificationService = FakeSessionVerificationService(),
redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(), redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
endPollAction = FakeEndPollAction(), endPollAction = endPollAction,
sendPollResponseAction = FakeSendPollResponseAction(), sendPollResponseAction = FakeSendPollResponseAction(),
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
) )

View file

@ -326,8 +326,7 @@ class MessagesViewTest {
state = state, state = state,
onUserDataClicked = callback, onUserDataClicked = callback,
) )
val senderName = (timelineItem as? TimelineItem.Event)?.senderDisplayName.orEmpty() rule.onNodeWithTag(TestTags.timelineItemSenderInfo.value).performClick()
rule.onNodeWithText(senderName).performClick()
} }
} }

View file

@ -87,10 +87,13 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import uniffi.wysiwyg_composer.MentionsState import uniffi.wysiwyg_composer.MentionsState
import java.io.File import java.io.File
@Suppress("LargeClass") @Suppress("LargeClass")
@RunWith(RobolectricTestRunner::class)
class MessageComposerPresenterTest { class MessageComposerPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@ -875,6 +878,19 @@ class MessageComposerPresenterTest {
} }
} }
@Test
fun `present - send uri`() = runTest {
val presenter = createPresenter(this)
moleculeFlow(RecompositionMode.Immediate) {
val state = presenter.present()
remember(state, state.richTextEditorState.messageHtml) { state }
}.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(MessageComposerEvents.SendUri(Uri.parse("content://uri")))
waitForPredicate { mediaPreProcessor.processCallCount == 1 }
}
}
@Test @Test
fun `present - handle typing notice event`() = runTest { fun `present - handle typing notice event`() = runTest {
val room = FakeMatrixRoom() val room = FakeMatrixRoom()

View file

@ -26,8 +26,6 @@ import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesS
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.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3 import io.element.android.libraries.matrix.test.A_USER_ID_3
@ -178,7 +176,6 @@ class TypingNotificationPresenterTest {
@Test @Test
fun `present - reserveSpace becomes true once we get the first typing notification with room members`() = runTest { fun `present - reserveSpace becomes true once we get the first typing notification with room members`() = runTest {
val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2)
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = createPresenter(matrixRoom = room) val presenter = createPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -216,27 +213,17 @@ class TypingNotificationPresenterTest {
private fun createDefaultRoomMember( private fun createDefaultRoomMember(
userId: UserId, userId: UserId,
) = RoomMember( ) = aTypingRoomMember(
userId = userId, userId = userId,
displayName = null, displayName = null,
avatarUrl = null,
membership = RoomMembershipState.JOIN,
isNameAmbiguous = false, isNameAmbiguous = false,
powerLevel = 0,
normalizedPowerLevel = 0,
isIgnored = false,
) )
private fun createKnownRoomMember( private fun createKnownRoomMember(
userId: UserId, userId: UserId,
) = RoomMember( ) = aTypingRoomMember(
userId = userId, userId = userId,
displayName = "Alice Doe", displayName = "Alice Doe",
avatarUrl = "an_avatar_url",
membership = RoomMembershipState.JOIN,
isNameAmbiguous = true, isNameAmbiguous = true,
powerLevel = 0,
normalizedPowerLevel = 0,
isIgnored = false,
) )
} }

View file

@ -23,6 +23,11 @@ plugins {
android { android {
namespace = "io.element.android.features.roomdetails.impl" namespace = "io.element.android.features.roomdetails.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
} }
anvil { anvil {
@ -61,6 +66,7 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(libs.test.mockk) testImplementation(libs.test.mockk)
testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediapickers.test)
@ -70,6 +76,8 @@ dependencies {
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(projects.features.leaveroom.test) testImplementation(projects.features.leaveroom.test)
testImplementation(projects.features.createroom.test) testImplementation(projects.features.createroom.test)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
} }

View file

@ -55,6 +55,7 @@ fun aDmRoomMember(
powerLevel: Long = 0, powerLevel: Long = 0,
normalizedPowerLevel: Long = powerLevel, normalizedPowerLevel: Long = powerLevel,
isIgnored: Boolean = false, isIgnored: Boolean = false,
role: RoomMember.Role = RoomMember.Role.USER,
) = RoomMember( ) = RoomMember(
userId = userId, userId = userId,
displayName = displayName, displayName = displayName,
@ -64,6 +65,7 @@ fun aDmRoomMember(
powerLevel = powerLevel, powerLevel = powerLevel,
normalizedPowerLevel = normalizedPowerLevel, normalizedPowerLevel = normalizedPowerLevel,
isIgnored = isIgnored, isIgnored = isIgnored,
role = role,
) )
fun aRoomDetailsState() = RoomDetailsState( fun aRoomDetailsState() = RoomDetailsState(

View file

@ -55,7 +55,10 @@ fun BlockUserDialogs(state: RoomMemberDetailsState) {
} }
@Composable @Composable
private fun BlockConfirmationDialog(onBlockAction: () -> Unit, onDismiss: () -> Unit) { private fun BlockConfirmationDialog(
onBlockAction: () -> Unit,
onDismiss: () -> Unit,
) {
ConfirmationDialog( ConfirmationDialog(
title = stringResource(R.string.screen_dm_details_block_user), title = stringResource(R.string.screen_dm_details_block_user),
content = stringResource(R.string.screen_dm_details_block_alert_description), content = stringResource(R.string.screen_dm_details_block_alert_description),
@ -66,7 +69,10 @@ private fun BlockConfirmationDialog(onBlockAction: () -> Unit, onDismiss: () ->
} }
@Composable @Composable
private fun UnblockConfirmationDialog(onUnblockAction: () -> Unit, onDismiss: () -> Unit) { private fun UnblockConfirmationDialog(
onUnblockAction: () -> Unit,
onDismiss: () -> Unit,
) {
ConfirmationDialog( ConfirmationDialog(
title = stringResource(R.string.screen_dm_details_unblock_user), title = stringResource(R.string.screen_dm_details_unblock_user),
content = stringResource(R.string.screen_dm_details_unblock_alert_description), content = stringResource(R.string.screen_dm_details_unblock_alert_description),

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.members
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.ui.room.sortingName
import java.text.Collator
// Comparator used to sort room members by power level (descending) and then by name (ascending)
internal class PowerLevelRoomMemberComparator : Comparator<RoomMember> {
// Used to simplify and compare unicode and ASCII chars (á == a)
private val collator = Collator.getInstance().apply {
decomposition = Collator.CANONICAL_DECOMPOSITION
}
override fun compare(o1: RoomMember, o2: RoomMember): Int {
return when {
o1.powerLevel > o2.powerLevel -> return -1
o1.powerLevel < o2.powerLevel -> return 1
else -> {
collator.compare(o1.sortingName(), o2.sortingName())
}
}
}
}

View file

@ -66,7 +66,9 @@ class RoomMemberListPresenter @Inject constructor(
roomMembers = AsyncData.Success( roomMembers = AsyncData.Success(
RoomMembers( RoomMembers(
invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(), invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(), joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList())
.sortedWith(PowerLevelRoomMemberComparator())
.toImmutableList(),
) )
) )
} }
@ -84,7 +86,9 @@ class RoomMemberListPresenter @Inject constructor(
SearchBarResultState.Results( SearchBarResultState.Results(
RoomMembers( RoomMembers(
invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(), invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(), joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList())
.sortedWith(PowerLevelRoomMemberComparator())
.toImmutableList(),
) )
) )
} }

View file

@ -31,7 +31,7 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
roomMembers = AsyncData.Success( roomMembers = AsyncData.Success(
RoomMembers( RoomMembers(
invited = persistentListOf(aVictor(), aWalter()), invited = persistentListOf(aVictor(), aWalter()),
joined = persistentListOf(anAlice(), aBob()), joined = persistentListOf(anAlice(), aBob(), aWalter()),
) )
) )
), ),
@ -79,6 +79,7 @@ fun aRoomMember(
powerLevel: Long = 0L, powerLevel: Long = 0L,
normalizedPowerLevel: Long = 0L, normalizedPowerLevel: Long = 0L,
isIgnored: Boolean = false, isIgnored: Boolean = false,
role: RoomMember.Role = RoomMember.Role.USER,
) = RoomMember( ) = RoomMember(
userId = userId, userId = userId,
displayName = displayName, displayName = displayName,
@ -88,6 +89,7 @@ fun aRoomMember(
powerLevel = powerLevel, powerLevel = powerLevel,
normalizedPowerLevel = normalizedPowerLevel, normalizedPowerLevel = normalizedPowerLevel,
isIgnored = isIgnored, isIgnored = isIgnored,
role = role,
) )
fun aRoomMemberList() = persistentListOf( fun aRoomMemberList() = persistentListOf(
@ -103,8 +105,8 @@ fun aRoomMemberList() = persistentListOf(
aWalter(), aWalter(),
) )
fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice") fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice", role = RoomMember.Role.ADMIN)
fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob") fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob", role = RoomMember.Role.MODERATOR)
fun aVictor() = aRoomMember(UserId("@victor:server.org"), "Victor", membership = RoomMembershipState.INVITE) fun aVictor() = aRoomMember(UserId("@victor:server.org"), "Victor", membership = RoomMembershipState.INVITE)

View file

@ -177,14 +177,28 @@ private fun RoomMemberListItem(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val roleText = when (roomMember.role) {
RoomMember.Role.ADMIN -> stringResource(R.string.screen_room_member_list_role_administrator)
RoomMember.Role.MODERATOR -> stringResource(R.string.screen_room_member_list_role_moderator)
RoomMember.Role.USER -> null
}
MatrixUserRow( MatrixUserRow(
modifier = modifier.clickable(onClick = onClick), modifier = modifier.clickable(onClick = onClick),
matrixUser = MatrixUser( matrixUser = MatrixUser(
userId = roomMember.userId, userId = roomMember.userId,
displayName = roomMember.displayName, displayName = roomMember.displayName,
avatarUrl = roomMember.avatarUrl avatarUrl = roomMember.avatarUrl,
), ),
avatarSize = AvatarSize.UserListItem, avatarSize = AvatarSize.UserListItem,
trailingContent = roleText?.let {
@Composable {
Text(
text = it,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
) )
} }

View file

@ -19,28 +19,38 @@ package io.element.android.features.roomdetails.impl.members.details
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.RoomId
open class RoomMemberDetailsStateProvider : PreviewParameterProvider<RoomMemberDetailsState> { open class RoomMemberDetailsStateProvider : PreviewParameterProvider<RoomMemberDetailsState> {
override val values: Sequence<RoomMemberDetailsState> override val values: Sequence<RoomMemberDetailsState>
get() = sequenceOf( get() = sequenceOf(
aRoomMemberDetailsState(), aRoomMemberDetailsState(),
aRoomMemberDetailsState().copy(userName = null), aRoomMemberDetailsState(userName = null),
aRoomMemberDetailsState().copy(isBlocked = AsyncData.Success(true)), aRoomMemberDetailsState(isBlocked = AsyncData.Success(true)),
aRoomMemberDetailsState().copy(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block), aRoomMemberDetailsState(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block),
aRoomMemberDetailsState().copy(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock), aRoomMemberDetailsState(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock),
aRoomMemberDetailsState().copy(isBlocked = AsyncData.Loading(true)), aRoomMemberDetailsState(isBlocked = AsyncData.Loading(true)),
aRoomMemberDetailsState().copy(startDmActionState = AsyncAction.Loading), aRoomMemberDetailsState(startDmActionState = AsyncAction.Loading),
// Add other states here // Add other states here
) )
} }
fun aRoomMemberDetailsState() = RoomMemberDetailsState( fun aRoomMemberDetailsState(
userId = "@daniel:domain.com", userId: String = "@daniel:domain.com",
userName = "Daniel", userName: String? = "Daniel",
avatarUrl = null, avatarUrl: String? = null,
isBlocked = AsyncData.Success(false), isBlocked: AsyncData<Boolean> = AsyncData.Success(false),
startDmActionState = AsyncAction.Uninitialized, startDmActionState: AsyncAction<RoomId> = AsyncAction.Uninitialized,
displayConfirmationDialog = null, displayConfirmationDialog: RoomMemberDetailsState.ConfirmationDialog? = null,
isCurrentUser = false, isCurrentUser: Boolean = false,
eventSink = {}, eventSink: (RoomMemberDetailsEvents) -> Unit = {},
) = RoomMemberDetailsState(
userId = userId,
userName = userName,
avatarUrl = avatarUrl,
isBlocked = isBlocked,
startDmActionState = startDmActionState,
displayConfirmationDialog = displayConfirmationDialog,
isCurrentUser = isCurrentUser,
eventSink = eventSink,
) )

View file

@ -0,0 +1,96 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.blockuser
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomdetails.impl.R
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
import io.element.android.features.roomdetails.impl.members.details.aRoomMemberDetailsState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class BlockUserDialogsTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `confirm block user emit expected Event`() {
val eventsRecorder = EventsRecorder<RoomMemberDetailsEvents>()
rule.setContent {
BlockUserDialogs(
state = aRoomMemberDetailsState(
displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block,
eventSink = eventsRecorder,
)
)
}
rule.clickOn(R.string.screen_dm_details_block_alert_action)
eventsRecorder.assertSingle(RoomMemberDetailsEvents.BlockUser(false))
}
@Test
fun `cancel block user emit expected Event`() {
val eventsRecorder = EventsRecorder<RoomMemberDetailsEvents>()
rule.setContent {
BlockUserDialogs(
state = aRoomMemberDetailsState(
displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block,
eventSink = eventsRecorder,
)
)
}
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(RoomMemberDetailsEvents.ClearConfirmationDialog)
}
@Test
fun `confirm unblock user emit expected Event`() {
val eventsRecorder = EventsRecorder<RoomMemberDetailsEvents>()
rule.setContent {
BlockUserDialogs(
state = aRoomMemberDetailsState(
displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock,
eventSink = eventsRecorder,
)
)
}
rule.clickOn(R.string.screen_dm_details_unblock_alert_action)
eventsRecorder.assertSingle(RoomMemberDetailsEvents.UnblockUser(false))
}
@Test
fun `cancel unblock user emit expected Event`() {
val eventsRecorder = EventsRecorder<RoomMemberDetailsEvents>()
rule.setContent {
BlockUserDialogs(
state = aRoomMemberDetailsState(
displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock,
eventSink = eventsRecorder,
)
)
}
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(RoomMemberDetailsEvents.ClearConfirmationDialog)
}
}

View file

@ -0,0 +1,89 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.members
import io.element.android.features.roomdetails.impl.members.PowerLevelRoomMemberComparator
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.A_USER_ID_5
import org.junit.Test
class PowerLevelRoomMemberComparatorTest {
@Test
fun `order is Admin, then Moderator, then User`() {
val memberList = listOf(
aRoomMember(userId = UserId("@admin:example.com"), powerLevel = 100),
aRoomMember(userId = UserId("@moderator:example.com"), powerLevel = 50),
aRoomMember(userId = UserId("@user:example.com"), powerLevel = 0),
).shuffled()
val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator())
assert(ordered[0].userId == UserId("@admin:example.com"))
assert(ordered[1].userId == UserId("@moderator:example.com"))
assert(ordered[2].userId == UserId("@user:example.com"))
}
@Test
fun `with the same power level, alphabetical ascending order for name is used`() {
val memberList = listOf(
aRoomMember(userId = A_USER_ID, displayName = "First - admin", powerLevel = 100),
aRoomMember(userId = A_USER_ID_2, displayName = "Second - admin", powerLevel = 100),
aRoomMember(userId = A_USER_ID_3, displayName = "Third - admin", powerLevel = 100),
aRoomMember(userId = A_USER_ID_4, displayName = "First - user", powerLevel = 0),
aRoomMember(userId = A_USER_ID_5, displayName = "Second - user", powerLevel = 0),
).shuffled()
val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator())
assert(ordered[0].userId == A_USER_ID)
assert(ordered[1].userId == A_USER_ID_2)
assert(ordered[2].userId == A_USER_ID_3)
assert(ordered[3].userId == A_USER_ID_4)
assert(ordered[4].userId == A_USER_ID_5)
}
@Test
fun `when no names are provided, alphabetical order uses user id`() {
val memberList = listOf(
aRoomMember(userId = A_USER_ID, displayName = "Z - LAST!", powerLevel = 100),
aRoomMember(userId = A_USER_ID_2, powerLevel = 100),
aRoomMember(userId = A_USER_ID_3, powerLevel = 100),
).shuffled()
val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator())
assert(ordered[0].userId == A_USER_ID_2)
assert(ordered[1].userId == A_USER_ID_3)
assert(ordered[2].userId == A_USER_ID)
}
@Test
fun `unicode characters are simplified and compared, order ignores case`() {
val memberList = listOf(
aRoomMember(userId = A_USER_ID, displayName = "First", powerLevel = 100),
aRoomMember(userId = A_USER_ID_2, displayName = "Șecond", powerLevel = 100),
aRoomMember(userId = A_USER_ID_3, displayName = "third", powerLevel = 100),
).shuffled()
val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator())
assert(ordered[0].userId == A_USER_ID)
assert(ordered[1].userId == A_USER_ID_2)
assert(ordered[2].userId == A_USER_ID_3)
}
}

View file

@ -20,7 +20,6 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
sealed interface RoomListEvents { sealed interface RoomListEvents {
data class UpdateFilter(val newFilter: String) : RoomListEvents
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
data object DismissRequestVerificationPrompt : RoomListEvents data object DismissRequestVerificationPrompt : RoomListEvents
data object DismissRecoveryKeyPrompt : RoomListEvents data object DismissRecoveryKeyPrompt : RoomListEvents

View file

@ -38,6 +38,8 @@ import io.element.android.features.roomlist.impl.datasource.InviteStateDataSourc
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.filters.RoomListFiltersPresenter import io.element.android.features.roomlist.impl.filters.RoomListFiltersPresenter
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
import io.element.android.features.roomlist.impl.search.RoomListSearchEvents
import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.libraries.architecture.AsyncData 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.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@ -74,13 +76,15 @@ class RoomListPresenter @Inject constructor(
private val inviteStateDataSource: InviteStateDataSource, private val inviteStateDataSource: InviteStateDataSource,
private val leaveRoomPresenter: LeaveRoomPresenter, private val leaveRoomPresenter: LeaveRoomPresenter,
private val roomListDataSource: RoomListDataSource, private val roomListDataSource: RoomListDataSource,
private val encryptionService: EncryptionService,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val indicatorService: IndicatorService, private val indicatorService: IndicatorService,
private val filtersPresenter: RoomListFiltersPresenter, private val filtersPresenter: RoomListFiltersPresenter,
private val searchPresenter: Presenter<RoomListSearchState>,
private val migrationScreenPresenter: MigrationScreenPresenter, private val migrationScreenPresenter: MigrationScreenPresenter,
private val sessionPreferencesStore: SessionPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore,
) : Presenter<RoomListState> { ) : Presenter<RoomListState> {
private val encryptionService: EncryptionService = client.encryptionService()
@Composable @Composable
override fun present(): RoomListState { override fun present(): RoomListState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@ -91,10 +95,10 @@ class RoomListPresenter @Inject constructor(
val roomList by produceState(initialValue = AsyncData.Loading()) { val roomList by produceState(initialValue = AsyncData.Loading()) {
roomListDataSource.allRooms.collect { value = AsyncData.Success(it) } roomListDataSource.allRooms.collect { value = AsyncData.Success(it) }
} }
val filteredRoomList by roomListDataSource.filteredRooms.collectAsState()
val filter by roomListDataSource.filter.collectAsState()
val networkConnectionStatus by networkMonitor.connectivity.collectAsState() val networkConnectionStatus by networkMonitor.connectivity.collectAsState()
val filtersState = filtersPresenter.present() val filtersState = filtersPresenter.present()
val searchState = searchPresenter.present()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
roomListDataSource.launchIn(this) roomListDataSource.launchIn(this)
@ -125,21 +129,14 @@ class RoomListPresenter @Inject constructor(
// Avatar indicator // Avatar indicator
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator() val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
var displaySearchResults by rememberSaveable { mutableStateOf(false) }
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) } val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
fun handleEvents(event: RoomListEvents) { fun handleEvents(event: RoomListEvents) {
when (event) { when (event) {
is RoomListEvents.UpdateFilter -> roomListDataSource.updateFilter(event.newFilter)
is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range) is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range)
RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true
RoomListEvents.DismissRecoveryKeyPrompt -> recoveryKeyPromptDismissed = true RoomListEvents.DismissRecoveryKeyPrompt -> recoveryKeyPromptDismissed = true
RoomListEvents.ToggleSearchResults -> { RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
if (displaySearchResults) {
roomListDataSource.updateFilter("")
}
displaySearchResults = !displaySearchResults
}
is RoomListEvents.ShowContextMenu -> { is RoomListEvents.ShowContextMenu -> {
coroutineScope.showContextMenu(event, contextMenu) coroutineScope.showContextMenu(event, contextMenu)
} }
@ -147,7 +144,6 @@ class RoomListPresenter @Inject constructor(
contextMenu.value = RoomListState.ContextMenu.Hidden contextMenu.value = RoomListState.ContextMenu.Hidden
} }
is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(event.roomId)) is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(event.roomId))
is RoomListEvents.SetRoomIsFavorite -> coroutineScope.launch { is RoomListEvents.SetRoomIsFavorite -> coroutineScope.launch {
client.getRoom(event.roomId)?.use { room -> client.getRoom(event.roomId)?.use { room ->
room.setIsFavorite(event.isFavorite) room.setIsFavorite(event.isFavorite)
@ -178,17 +174,15 @@ class RoomListPresenter @Inject constructor(
matrixUser = matrixUser.value, matrixUser = matrixUser.value,
showAvatarIndicator = showAvatarIndicator, showAvatarIndicator = showAvatarIndicator,
roomList = roomList, roomList = roomList,
filter = filter,
filteredRoomList = filteredRoomList,
displayVerificationPrompt = displayVerificationPrompt, displayVerificationPrompt = displayVerificationPrompt,
displayRecoveryKeyPrompt = displayRecoveryKeyPrompt, displayRecoveryKeyPrompt = displayRecoveryKeyPrompt,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
invitesState = inviteStateDataSource.inviteState(), invitesState = inviteStateDataSource.inviteState(),
displaySearchResults = displaySearchResults,
contextMenu = contextMenu.value, contextMenu = contextMenu.value,
leaveRoomState = leaveRoomState, leaveRoomState = leaveRoomState,
filtersState = filtersState, filtersState = filtersState,
searchState = searchState,
displayMigrationStatus = isMigrating, displayMigrationStatus = isMigrating,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )

View file

@ -20,6 +20,7 @@ import androidx.compose.runtime.Immutable
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -31,17 +32,15 @@ data class RoomListState(
val matrixUser: MatrixUser?, val matrixUser: MatrixUser?,
val showAvatarIndicator: Boolean, val showAvatarIndicator: Boolean,
val roomList: AsyncData<ImmutableList<RoomListRoomSummary>>, val roomList: AsyncData<ImmutableList<RoomListRoomSummary>>,
val filter: String?,
val filteredRoomList: ImmutableList<RoomListRoomSummary>,
val displayVerificationPrompt: Boolean, val displayVerificationPrompt: Boolean,
val displayRecoveryKeyPrompt: Boolean, val displayRecoveryKeyPrompt: Boolean,
val hasNetworkConnection: Boolean, val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val invitesState: InvitesState, val invitesState: InvitesState,
val displaySearchResults: Boolean,
val contextMenu: ContextMenu, val contextMenu: ContextMenu,
val leaveRoomState: LeaveRoomState, val leaveRoomState: LeaveRoomState,
val filtersState: RoomListFiltersState, val filtersState: RoomListFiltersState,
val searchState: RoomListSearchState,
val displayMigrationStatus: Boolean, val displayMigrationStatus: Boolean,
val eventSink: (RoomListEvents) -> Unit, val eventSink: (RoomListEvents) -> Unit,
) { ) {

View file

@ -22,6 +22,7 @@ import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryF
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary
import io.element.android.features.roomlist.impl.search.aRoomListSearchState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -42,14 +43,13 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState().copy(hasNetworkConnection = false), aRoomListState().copy(hasNetworkConnection = false),
aRoomListState().copy(invitesState = InvitesState.SeenInvites), aRoomListState().copy(invitesState = InvitesState.SeenInvites),
aRoomListState().copy(invitesState = InvitesState.NewInvites), aRoomListState().copy(invitesState = InvitesState.NewInvites),
aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()),
aRoomListState().copy(displaySearchResults = true),
aRoomListState().copy(contextMenu = aContextMenuShown(roomName = "A nice room name")), aRoomListState().copy(contextMenu = aContextMenuShown(roomName = "A nice room name")),
aRoomListState().copy(contextMenu = aContextMenuShown(isFavorite = true)), aRoomListState().copy(contextMenu = aContextMenuShown(isFavorite = true)),
aRoomListState().copy(displayRecoveryKeyPrompt = true), aRoomListState().copy(displayRecoveryKeyPrompt = true),
aRoomListState().copy(roomList = AsyncData.Success(persistentListOf())), aRoomListState().copy(roomList = AsyncData.Success(persistentListOf())),
aRoomListState().copy(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())), aRoomListState().copy(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())),
aRoomListState().copy(matrixUser = null, displayMigrationStatus = true), aRoomListState().copy(matrixUser = null, displayMigrationStatus = true),
aRoomListState().copy(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
) )
} }
@ -57,17 +57,15 @@ internal fun aRoomListState() = RoomListState(
matrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"), matrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"),
showAvatarIndicator = false, showAvatarIndicator = false,
roomList = AsyncData.Success(aRoomListRoomSummaryList()), roomList = AsyncData.Success(aRoomListRoomSummaryList()),
filter = "filter",
filteredRoomList = aRoomListRoomSummaryList(),
hasNetworkConnection = true, hasNetworkConnection = true,
snackbarMessage = null, snackbarMessage = null,
displayVerificationPrompt = false, displayVerificationPrompt = false,
displayRecoveryKeyPrompt = false, displayRecoveryKeyPrompt = false,
invitesState = InvitesState.NoInvites, invitesState = InvitesState.NoInvites,
displaySearchResults = false,
contextMenu = RoomListState.ContextMenu.Hidden, contextMenu = RoomListState.ContextMenu.Hidden,
leaveRoomState = aLeaveRoomState(), leaveRoomState = aLeaveRoomState(),
filtersState = aRoomListFiltersState(), filtersState = aRoomListFiltersState(),
searchState = aRoomListSearchState(),
displayMigrationStatus = false, displayMigrationStatus = false,
eventSink = {} eventSink = {}
) )

View file

@ -58,7 +58,7 @@ import io.element.android.features.roomlist.impl.components.RoomSummaryRow
import io.element.android.features.roomlist.impl.filters.RoomListFiltersView import io.element.android.features.roomlist.impl.filters.RoomListFiltersView
import io.element.android.features.roomlist.impl.migration.MigrationScreenView import io.element.android.features.roomlist.impl.migration.MigrationScreenView
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchResultView import io.element.android.features.roomlist.impl.search.RoomListSearchView
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -119,8 +119,8 @@ fun RoomListView(
onMenuActionClicked = onMenuActionClicked, onMenuActionClicked = onMenuActionClicked,
) )
// This overlaid view will only be visible when state.displaySearchResults is true // This overlaid view will only be visible when state.displaySearchResults is true
RoomListSearchResultView( RoomListSearchView(
state = state, state = state.searchState,
onRoomClicked = onRoomClicked, onRoomClicked = onRoomClicked,
onRoomLongClicked = { onRoomLongClicked(it) }, onRoomLongClicked = { onRoomLongClicked(it) },
modifier = Modifier modifier = Modifier
@ -209,8 +209,7 @@ private fun RoomListContent(
RoomListTopBar( RoomListTopBar(
matrixUser = state.matrixUser, matrixUser = state.matrixUser,
showAvatarIndicator = state.showAvatarIndicator, showAvatarIndicator = state.showAvatarIndicator,
areSearchResultsDisplayed = state.displaySearchResults, areSearchResultsDisplayed = state.searchState.isSearchActive,
onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) }, onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) },
onMenuActionClicked = onMenuActionClicked, onMenuActionClicked = onMenuActionClicked,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,

View file

@ -16,7 +16,6 @@
package io.element.android.features.roomlist.impl.components package io.element.android.features.roomlist.impl.components
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -87,7 +86,6 @@ fun RoomListTopBar(
matrixUser: MatrixUser?, matrixUser: MatrixUser?,
showAvatarIndicator: Boolean, showAvatarIndicator: Boolean,
areSearchResultsDisplayed: Boolean, areSearchResultsDisplayed: Boolean,
onFilterChanged: (String) -> Unit,
onToggleSearch: () -> Unit, onToggleSearch: () -> Unit,
onMenuActionClicked: (RoomListMenuAction) -> Unit, onMenuActionClicked: (RoomListMenuAction) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
@ -95,15 +93,6 @@ fun RoomListTopBar(
displayMenuItems: Boolean, displayMenuItems: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun closeFilter() {
onFilterChanged("")
}
BackHandler(enabled = areSearchResultsDisplayed) {
closeFilter()
onToggleSearch()
}
DefaultRoomListTopBar( DefaultRoomListTopBar(
matrixUser = matrixUser, matrixUser = matrixUser,
showAvatarIndicator = showAvatarIndicator, showAvatarIndicator = showAvatarIndicator,

View file

@ -25,15 +25,11 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -54,9 +50,7 @@ class RoomListDataSource @Inject constructor(
observeNotificationSettings() observeNotificationSettings()
} }
private val _filter = MutableStateFlow("")
private val _allRooms = MutableSharedFlow<ImmutableList<RoomListRoomSummary>>(replay = 1) private val _allRooms = MutableSharedFlow<ImmutableList<RoomListRoomSummary>>(replay = 1)
private val _filteredRooms = MutableStateFlow<ImmutableList<RoomListRoomSummary>>(persistentListOf())
private val lock = Mutex() private val lock = Mutex()
private val diffCache = MutableListDiffCache<RoomListRoomSummary>() private val diffCache = MutableListDiffCache<RoomListRoomSummary>()
@ -72,29 +66,9 @@ class RoomListDataSource @Inject constructor(
replaceWith(roomSummaries) replaceWith(roomSummaries)
} }
.launchIn(coroutineScope) .launchIn(coroutineScope)
combine(
_filter,
_allRooms
) { filterValue, allRoomsValue ->
when {
filterValue.isEmpty() -> emptyList()
else -> allRoomsValue.filter { it.name.contains(filterValue, ignoreCase = true) }
}.toImmutableList()
}
.onEach {
_filteredRooms.value = it
}
.launchIn(coroutineScope)
} }
fun updateFilter(filterValue: String) {
_filter.value = filterValue
}
val filter: StateFlow<String> = _filter
val allRooms: SharedFlow<ImmutableList<RoomListRoomSummary>> = _allRooms val allRooms: SharedFlow<ImmutableList<RoomListRoomSummary>> = _allRooms
val filteredRooms: StateFlow<ImmutableList<RoomListRoomSummary>> = _filteredRooms
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
private fun observeNotificationSettings() { private fun observeNotificationSettings() {

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.roomlist.impl.search.RoomListSearchPresenter
import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.SessionScope
@ContributesTo(SessionScope::class)
@Module
interface RoomListModule {
@Binds
fun bindSearchPresenter(presenter: RoomListSearchPresenter): Presenter<RoomListSearchState>
}

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.search
sealed interface RoomListSearchEvents {
data object ToggleSearchVisibility : RoomListSearchEvents
data class QueryChanged(val query: String) : RoomListSearchEvents
data object ClearQuery : RoomListSearchEvents
}

View file

@ -0,0 +1,118 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.search
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import javax.inject.Inject
private const val PAGE_SIZE = 50
class RoomListSearchPresenter @Inject constructor(
private val roomListService: RoomListService,
private val roomSummaryFactory: RoomListRoomSummaryFactory,
private val coroutineDispatchers: CoroutineDispatchers,
) : Presenter<RoomListSearchState> {
@Composable
override fun present(): RoomListSearchState {
var isSearchActive by rememberSaveable {
mutableStateOf(false)
}
var searchQuery by rememberSaveable {
mutableStateOf("")
}
val coroutineScope = rememberCoroutineScope()
val roomList = remember {
roomListService.createRoomList(
coroutineScope = coroutineScope,
pageSize = PAGE_SIZE,
initialFilter = RoomListFilter.all(RoomListFilter.None),
source = RoomList.Source.All,
)
}
LaunchedEffect(Unit) {
roomList.loadAllIncrementally(this)
}
LaunchedEffect(key1 = searchQuery) {
val filter = if (searchQuery.isBlank()) {
RoomListFilter.all(RoomListFilter.None)
} else {
RoomListFilter.all(RoomListFilter.NonLeft, RoomListFilter.NormalizedMatchRoomName(searchQuery))
}
roomList.updateFilter(filter)
}
fun handleEvents(event: RoomListSearchEvents) {
when (event) {
RoomListSearchEvents.ClearQuery -> {
searchQuery = ""
}
is RoomListSearchEvents.QueryChanged -> {
searchQuery = event.query
}
RoomListSearchEvents.ToggleSearchVisibility -> {
isSearchActive = !isSearchActive
searchQuery = ""
}
}
}
val searchResults by roomList
.rememberMappedSummaries()
.collectAsState(initial = persistentListOf())
return RoomListSearchState(
isSearchActive = isSearchActive,
query = searchQuery,
results = searchResults,
eventSink = ::handleEvents
)
}
@Composable
private fun RoomList.rememberMappedSummaries() = remember {
summaries
.map { roomSummaries ->
roomSummaries
.filterIsInstance<RoomSummary.Filled>()
.map(roomSummaryFactory::create)
.toPersistentList()
}
.flowOn(coroutineDispatchers.computation)
}
}

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.search
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import kotlinx.collections.immutable.ImmutableList
data class RoomListSearchState(
val isSearchActive: Boolean,
val query: String,
val results: ImmutableList<RoomListRoomSummary>,
val eventSink: (RoomListSearchEvents) -> Unit
)

View file

@ -0,0 +1,47 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.search
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomlist.impl.aRoomListRoomSummaryList
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
class RoomListSearchStateProvider : PreviewParameterProvider<RoomListSearchState> {
override val values: Sequence<RoomListSearchState>
get() = sequenceOf(
aRoomListSearchState(),
aRoomListSearchState(
isSearchActive = true,
query = "Test",
results = aRoomListRoomSummaryList()
),
)
}
fun aRoomListSearchState(
isSearchActive: Boolean = false,
query: String = "",
results: ImmutableList<RoomListRoomSummary> = persistentListOf(),
eventSink: (RoomListSearchEvents) -> Unit = { },
) = RoomListSearchState(
isSearchActive = isSearchActive,
query = query,
results = results,
eventSink = eventSink,
)

View file

@ -16,6 +16,7 @@
package io.element.android.features.roomlist.impl.search package io.element.android.features.roomlist.impl.search
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@ -25,32 +26,23 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Velocity import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomlist.impl.RoomListEvents
import io.element.android.features.roomlist.impl.RoomListState
import io.element.android.features.roomlist.impl.aRoomListState
import io.element.android.features.roomlist.impl.components.RoomSummaryRow import io.element.android.features.roomlist.impl.components.RoomSummaryRow
import io.element.android.features.roomlist.impl.contentType import io.element.android.features.roomlist.impl.contentType
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
@ -68,26 +60,30 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
internal fun RoomListSearchResultView( internal fun RoomListSearchView(
state: RoomListState, state: RoomListSearchState,
onRoomClicked: (RoomId) -> Unit, onRoomClicked: (RoomId) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit, onRoomLongClicked: (RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
BackHandler(enabled = state.isSearchActive) {
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
}
AnimatedVisibility( AnimatedVisibility(
visible = state.displaySearchResults, visible = state.isSearchActive,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
) { ) {
Column( Column(
modifier = modifier modifier = modifier
.applyIf(state.displaySearchResults, ifTrue = { .applyIf(state.isSearchActive, ifTrue = {
// Disable input interaction to underlying views // Disable input interaction to underlying views
pointerInput(Unit) {} pointerInput(Unit) {}
}) })
) { ) {
if (state.displaySearchResults) { if (state.isSearchActive) {
RoomListSearchResultContent( RoomListSearchContent(
state = state, state = state,
onRoomClicked = onRoomClicked, onRoomClicked = onRoomClicked,
onRoomLongClicked = onRoomLongClicked, onRoomLongClicked = onRoomLongClicked,
@ -99,15 +95,15 @@ internal fun RoomListSearchResultView(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun RoomListSearchResultContent( private fun RoomListSearchContent(
state: RoomListState, state: RoomListSearchState,
onRoomClicked: (RoomId) -> Unit, onRoomClicked: (RoomId) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit, onRoomLongClicked: (RoomListRoomSummary) -> Unit,
) { ) {
val borderColor = MaterialTheme.colorScheme.tertiary val borderColor = MaterialTheme.colorScheme.tertiary
val strokeWidth = 1.dp val strokeWidth = 1.dp
fun onBackButtonPressed() { fun onBackButtonPressed() {
state.eventSink(RoomListEvents.ToggleSearchResults) state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
} }
fun onRoomClicked(room: RoomListRoomSummary) { fun onRoomClicked(room: RoomListRoomSummary) {
@ -126,7 +122,7 @@ private fun RoomListSearchResultContent(
}, },
navigationIcon = { BackButton(onClick = ::onBackButtonPressed) }, navigationIcon = { BackButton(onClick = ::onBackButtonPressed) },
title = { title = {
val filter = state.filter.orEmpty() val filter = state.query
val focusRequester = FocusRequester() val focusRequester = FocusRequester()
TextField( TextField(
modifier = Modifier modifier = Modifier
@ -134,7 +130,7 @@ private fun RoomListSearchResultContent(
.focusRequester(focusRequester), .focusRequester(focusRequester),
value = filter, value = filter,
singleLine = true, singleLine = true,
onValueChange = { state.eventSink(RoomListEvents.UpdateFilter(it)) }, onValueChange = { state.eventSink(RoomListSearchEvents.QueryChanged(it)) },
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent,
@ -147,7 +143,7 @@ private fun RoomListSearchResultContent(
trailingIcon = { trailingIcon = {
if (filter.isNotEmpty()) { if (filter.isNotEmpty()) {
IconButton(onClick = { IconButton(onClick = {
state.eventSink(RoomListEvents.UpdateFilter("")) state.eventSink(RoomListSearchEvents.ClearQuery)
}) { }) {
Icon( Icon(
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
@ -158,8 +154,8 @@ private fun RoomListSearchResultContent(
} }
) )
LaunchedEffect(state.displaySearchResults) { LaunchedEffect(state.isSearchActive) {
if (state.displaySearchResults) { if (state.isSearchActive) {
focusRequester.requestFocus() focusRequester.requestFocus()
} }
} }
@ -168,39 +164,16 @@ private fun RoomListSearchResultContent(
) )
} }
) { padding -> ) { padding ->
val lazyListState = rememberLazyListState()
val visibleRange by remember {
derivedStateOf {
val layoutInfo = lazyListState.layoutInfo
val firstItemIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0
val size = layoutInfo.visibleItemsInfo.size
firstItemIndex until firstItemIndex + size
}
}
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override suspend fun onPostFling(
consumed: Velocity,
available: Velocity
): Velocity {
state.eventSink(RoomListEvents.UpdateVisibleRange(visibleRange))
return super.onPostFling(consumed, available)
}
}
}
Column( Column(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier.weight(1f),
.weight(1f)
.nestedScroll(nestedScrollConnection),
state = lazyListState,
) { ) {
items( items(
items = state.filteredRoomList, items = state.results,
contentType = { room -> room.contentType() }, contentType = { room -> room.contentType() },
) { room -> ) { room ->
RoomSummaryRow( RoomSummaryRow(
@ -216,9 +189,9 @@ private fun RoomListSearchResultContent(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun RoomListSearchResultContentPreview() = ElementPreview { internal fun RoomListSearchResultContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview {
RoomListSearchResultContent( RoomListSearchContent(
state = aRoomListState(), state = state,
onRoomClicked = {}, onRoomClicked = {},
onRoomLongClicked = {} onRoomLongClicked = {}
) )

View file

@ -34,19 +34,24 @@ import io.element.android.features.roomlist.impl.filters.RoomListFiltersPresente
import io.element.android.features.roomlist.impl.migration.InMemoryMigrationScreenStore import io.element.android.features.roomlist.impl.migration.InMemoryMigrationScreenStore
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
import io.element.android.features.roomlist.impl.model.createRoomListRoomSummary import io.element.android.features.roomlist.impl.model.createRoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchEvents
import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.features.roomlist.impl.search.aRoomListSearchState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
import io.element.android.libraries.indicator.impl.DefaultIndicatorService import io.element.android.libraries.indicator.impl.DefaultIndicatorService
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.ReceiptType
@ -55,7 +60,6 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu
import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION 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
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
@ -67,6 +71,7 @@ import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
@ -78,6 +83,7 @@ import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.seconds
class RoomListPresenterTests { class RoomListPresenterTests {
@get:Rule @get:Rule
@ -106,9 +112,12 @@ class RoomListPresenterTests {
fun `present - show avatar indicator`() = runTest { fun `present - show avatar indicator`() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob()) val scope = CoroutineScope(coroutineContext + SupervisorJob())
val encryptionService = FakeEncryptionService() val encryptionService = FakeEncryptionService()
val matrixClient = FakeMatrixClient(
encryptionService = encryptionService,
)
val sessionVerificationService = FakeSessionVerificationService() val sessionVerificationService = FakeSessionVerificationService()
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
encryptionService = encryptionService, client = matrixClient,
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
coroutineScope = scope coroutineScope = scope
) )
@ -146,24 +155,6 @@ class RoomListPresenterTests {
} }
} }
@Test
fun `present - should filter room with success`() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val withUserState = awaitItem()
assertThat(withUserState.filter).isEqualTo("")
withUserState.eventSink.invoke(RoomListEvents.UpdateFilter("t"))
val withFilterState = awaitItem()
assertThat(withFilterState.filter).isEqualTo("t")
cancelAndIgnoreRemainingEvents()
scope.cancel()
}
}
@Test @Test
fun `present - load 1 room with success`() = runTest { fun `present - load 1 room with success`() = runTest {
val roomListService = FakeRoomListService() val roomListService = FakeRoomListService()
@ -175,7 +166,7 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = consumeItemsUntilPredicate { state -> state.roomList.dataOrNull()?.size == 16 }.last() val initialState = consumeItemsUntilPredicate(timeout = 3.seconds) { state -> state.roomList.dataOrNull()?.size == 16 }.last()
// Room list is loaded with 16 placeholders // Room list is loaded with 16 placeholders
val initialItems = initialState.roomList.dataOrNull().orEmpty() val initialItems = initialState.roomList.dataOrNull().orEmpty()
assertThat(initialItems.size).isEqualTo(16) assertThat(initialItems.size).isEqualTo(16)
@ -197,51 +188,7 @@ class RoomListPresenterTests {
numberOfUnreadMessages = 2, numberOfUnreadMessages = 2,
) )
) )
scope.cancel() cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - load 1 room with success and filter rooms`() = runTest {
val roomListService = FakeRoomListService()
val matrixClient = FakeMatrixClient(
roomListService = roomListService
)
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
roomListService.postAllRooms(
listOf(
aRoomSummaryFilled(
numUnreadMentions = 1,
numUnreadMessages = 2,
)
)
)
skipItems(3)
val loadedState = awaitItem()
// Test filtering with result
assertThat(loadedState.roomList.dataOrNull().orEmpty().size).isEqualTo(1)
loadedState.eventSink.invoke(RoomListEvents.UpdateFilter(A_ROOM_NAME.substring(0, 3)))
skipItems(1)
val withFilteredRoomState = awaitItem()
assertThat(withFilteredRoomState.filteredRoomList.size).isEqualTo(1)
assertThat(withFilteredRoomState.filter).isEqualTo(A_ROOM_NAME.substring(0, 3))
assertThat(withFilteredRoomState.filteredRoomList.size).isEqualTo(1)
assertThat(withFilteredRoomState.filteredRoomList.first()).isEqualTo(
createRoomListRoomSummary(
numberOfUnreadMentions = 1,
numberOfUnreadMessages = 2,
)
)
// Test filtering without result
withFilteredRoomState.eventSink.invoke(RoomListEvents.UpdateFilter("tada"))
skipItems(1)
val withNotFilteredRoomState = awaitItem()
assertThat(withNotFilteredRoomState.filter).isEqualTo("tada")
assertThat(withNotFilteredRoomState.filteredRoomList).isEmpty()
scope.cancel() scope.cancel()
} }
} }
@ -315,6 +262,33 @@ class RoomListPresenterTests {
} }
} }
@Test
fun `present - handle DismissRecoveryKeyPrompt`() = runTest {
val encryptionService = FakeEncryptionService()
val matrixClient = FakeMatrixClient(
encryptionService = encryptionService,
)
val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(
client = matrixClient,
coroutineScope = scope,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.displayRecoveryKeyPrompt).isFalse()
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
val nextState = awaitItem()
assertThat(nextState.displayRecoveryKeyPrompt).isTrue()
nextState.eventSink(RoomListEvents.DismissRecoveryKeyPrompt)
val finalState = awaitItem()
assertThat(finalState.displayRecoveryKeyPrompt).isFalse()
scope.cancel()
}
}
@Test @Test
fun `present - sets invite state`() = runTest { fun `present - sets invite state`() = runTest {
val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites) val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites)
@ -443,6 +417,40 @@ class RoomListPresenterTests {
} }
} }
@Test
fun `present - toggle search menu`() = runTest {
val eventRecorder = EventsRecorder<RoomListSearchEvents>()
val searchPresenter: Presenter<RoomListSearchState> = Presenter {
aRoomListSearchState(
eventSink = eventRecorder
)
}
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(
coroutineScope = scope,
searchPresenter = searchPresenter,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
eventRecorder.assertEmpty()
initialState.eventSink(RoomListEvents.ToggleSearchResults)
eventRecorder.assertSingle(
RoomListSearchEvents.ToggleSearchVisibility
)
initialState.eventSink(RoomListEvents.ToggleSearchResults)
eventRecorder.assertList(
listOf(
RoomListSearchEvents.ToggleSearchVisibility,
RoomListSearchEvents.ToggleSearchVisibility
)
)
scope.cancel()
}
}
@Test @Test
fun `present - change in notification settings updates the summary for decorations`() = runTest { fun `present - change in notification settings updates the summary for decorations`() = runTest {
val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
@ -516,7 +524,6 @@ class RoomListPresenterTests {
// The migration screen is not shown anymore // The migration screen is not shown anymore
assertThat(awaitItem().displayMigrationStatus).isFalse() assertThat(awaitItem().displayMigrationStatus).isFalse()
cancelAndIgnoreRemainingEvents()
scope.cancel() scope.cancel()
} }
} }
@ -567,14 +574,15 @@ class RoomListPresenterTests {
givenFormat(A_FORMATTED_DATE) givenFormat(A_FORMATTED_DATE)
}, },
roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(), roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(),
encryptionService: EncryptionService = FakeEncryptionService(),
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(), sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)),
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
migrationScreenPresenter: MigrationScreenPresenter = MigrationScreenPresenter( migrationScreenPresenter: MigrationScreenPresenter = MigrationScreenPresenter(
matrixClient = client, matrixClient = client,
migrationScreenStore = InMemoryMigrationScreenStore(), migrationScreenStore = InMemoryMigrationScreenStore(),
), ),
filtersPresenter: RoomListFiltersPresenter = RoomListFiltersPresenter(), filtersPresenter: RoomListFiltersPresenter = RoomListFiltersPresenter(),
searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() },
) = RoomListPresenter( ) = RoomListPresenter(
client = client, client = client,
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
@ -592,14 +600,14 @@ class RoomListPresenterTests {
notificationSettingsService = client.notificationSettingsService(), notificationSettingsService = client.notificationSettingsService(),
appScope = coroutineScope appScope = coroutineScope
), ),
encryptionService = encryptionService, featureFlagService = featureFlagService,
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)),
indicatorService = DefaultIndicatorService( indicatorService = DefaultIndicatorService(
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
encryptionService = encryptionService, encryptionService = client.encryptionService(),
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)), featureFlagService = featureFlagService,
), ),
migrationScreenPresenter = migrationScreenPresenter, migrationScreenPresenter = migrationScreenPresenter,
searchPresenter = searchPresenter,
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
filtersPresenter = filtersPresenter, filtersPresenter = filtersPresenter,
) )

View file

@ -0,0 +1,151 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.search
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RoomListSearchPresenterTests {
@Test
fun `present - initial state`() = runTest {
val presenter = createRoomListSearchPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse()
assertThat(state.query).isEmpty()
assertThat(state.results).isEmpty()
}
}
}
@Test
fun `present - toggle search visibility`() = runTest {
val presenter = createRoomListSearchPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse()
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
}
awaitItem().let { state ->
assertThat(state.isSearchActive).isTrue()
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
}
awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse()
}
}
}
@Test
fun `present - query search changes`() = runTest {
val roomListService = FakeRoomListService()
val presenter = createRoomListSearchPresenter(roomListService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().let { state ->
assertThat(
roomListService.allRooms.currentFilter.value
).isEqualTo(
RoomListFilter.all(
RoomListFilter.None,
)
)
state.eventSink(RoomListSearchEvents.QueryChanged("Search"))
}
awaitItem().let { state ->
assertThat(state.query).isEqualTo("Search")
assertThat(
roomListService.allRooms.currentFilter.value
).isEqualTo(
RoomListFilter.all(
RoomListFilter.NonLeft,
RoomListFilter.NormalizedMatchRoomName("Search")
)
)
state.eventSink(RoomListSearchEvents.ClearQuery)
}
awaitItem().let { state ->
assertThat(state.query).isEmpty()
assertThat(
roomListService.allRooms.currentFilter.value
).isEqualTo(
RoomListFilter.all(
RoomListFilter.None,
)
)
}
}
}
@Test
fun `present - room list changes`() = runTest {
val roomListService = FakeRoomListService()
val presenter = createRoomListSearchPresenter(roomListService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().let { state ->
assertThat(state.results).isEmpty()
}
roomListService.postAllRooms(
listOf(
RoomSummary.Empty("1"),
aRoomSummaryFilled()
)
)
awaitItem().let { state ->
assertThat(state.results).hasSize(1)
}
roomListService.postAllRooms(emptyList())
awaitItem().let { state ->
assertThat(state.results).isEmpty()
}
}
}
}
fun TestScope.createRoomListSearchPresenter(
roomListService: RoomListService = FakeRoomListService(),
): RoomListSearchPresenter {
return RoomListSearchPresenter(
roomListService = roomListService,
roomSummaryFactory = RoomListRoomSummaryFactory(
lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(),
roomLastMessageFormatter = FakeRoomLastMessageFormatter(),
),
coroutineDispatchers = testCoroutineDispatchers(),
)
}

View file

@ -22,7 +22,7 @@ compose_bom = "2024.02.00"
composecompiler = "1.5.9" composecompiler = "1.5.9"
# Coroutines # Coroutines
coroutines = "1.7.3" coroutines = "1.8.0"
# Accompanist # Accompanist
accompanist = "0.34.0" accompanist = "0.34.0"
@ -34,7 +34,7 @@ test_core = "1.5.0"
coil = "2.5.0" coil = "2.5.0"
datetime = "0.5.0" datetime = "0.5.0"
dependencyAnalysis = "1.30.0" dependencyAnalysis = "1.30.0"
serialization_json = "1.6.2" serialization_json = "1.6.3"
showkase = "1.0.2" showkase = "1.0.2"
appyx = "1.4.0" appyx = "1.4.0"
sqldelight = "2.0.1" sqldelight = "2.0.1"
@ -51,7 +51,7 @@ autoservice = "1.1.1"
junit = "4.13.2" junit = "4.13.2"
androidx-test-ext-junit = "1.1.5" androidx-test-ext-junit = "1.1.5"
espresso-core = "3.5.1" espresso-core = "3.5.1"
kover = "0.7.5" kover = "0.7.6"
[libraries] [libraries]
# Project # Project
@ -132,7 +132,7 @@ test_junitext = "androidx.test.ext:junit:1.1.5"
test_mockk = "io.mockk:mockk:1.13.9" test_mockk = "io.mockk:mockk:1.13.9"
test_konsist = "com.lemonappdev:konsist:0.13.0" test_konsist = "com.lemonappdev:konsist:0.13.0"
test_turbine = "app.cash.turbine:turbine:1.0.0" test_turbine = "app.cash.turbine:turbine:1.0.0"
test_truth = "com.google.truth:truth:1.4.0" test_truth = "com.google.truth:truth:1.4.1"
test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.15" test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.15"
test_robolectric = "org.robolectric:robolectric:4.11.1" test_robolectric = "org.robolectric:robolectric:4.11.1"
test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" }
@ -163,7 +163,7 @@ sqlite = "androidx.sqlite:sqlite-ktx:2.4.0"
unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1" unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1"
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5" otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
vanniktech_blurhash = "com.vanniktech:blurhash:0.2.0" vanniktech_blurhash = "com.vanniktech:blurhash:0.2.0"
telephoto_zoomableimage = "me.saket.telephoto:zoomable-image-coil:0.7.1" telephoto_zoomableimage = "me.saket.telephoto:zoomable-image-coil:0.8.0"
statemachine = "com.freeletics.flowredux:compose:1.2.1" statemachine = "com.freeletics.flowredux:compose:1.2.1"
maplibre = "org.maplibre.gl:android-sdk:10.2.0" maplibre = "org.maplibre.gl:android-sdk:10.2.0"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.2" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.2"
@ -172,7 +172,7 @@ opusencoder = "io.element.android:opusencoder:1.1.0"
kotlinpoet = "com.squareup:kotlinpoet:1.16.0" kotlinpoet = "com.squareup:kotlinpoet:1.16.0"
# Analytics # Analytics
posthog = "com.posthog:posthog-android:3.1.7" posthog = "com.posthog:posthog-android:3.1.8"
sentry = "io.sentry:sentry-android:7.3.0" sentry = "io.sentry:sentry-android:7.3.0"
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:aa14cbcdf81af2746d20a71779ec751f971e1d7f" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:aa14cbcdf81af2746d20a71779ec751f971e1d7f"

View file

@ -33,6 +33,8 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
@Composable @Composable
fun FloatingActionButton( fun FloatingActionButton(
@ -48,7 +50,7 @@ fun FloatingActionButton(
) { ) {
androidx.compose.material3.FloatingActionButton( androidx.compose.material3.FloatingActionButton(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier.testTag(TestTags.floatingActionButton),
shape = shape, shape = shape,
containerColor = containerColor, containerColor = containerColor,
contentColor = contentColor, contentColor = contentColor,

View file

@ -27,7 +27,17 @@ data class RoomMember(
val powerLevel: Long, val powerLevel: Long,
val normalizedPowerLevel: Long, val normalizedPowerLevel: Long,
val isIgnored: Boolean, val isIgnored: Boolean,
val role: Role,
) { ) {
/**
* Role of the RoomMember, based on its [powerLevel].
*/
enum class Role {
ADMIN,
MODERATOR,
USER
}
/** /**
* Disambiguated display name for the RoomMember. * Disambiguated display name for the RoomMember.
* If the display name is null, the user ID is returned. * If the display name is null, the user ID is returned.
@ -49,6 +59,10 @@ enum class RoomMembershipState {
LEAVE LEAVE
} }
/**
* Returns the best name value to display for the RoomMember.
* If the [RoomMember.displayName] is present and not empty it'll be used, otherwise the [RoomMember.userId] will be used.
*/
fun RoomMember.getBestName(): String { fun RoomMember.getBestName(): String {
return displayName?.takeIf { it.isNotEmpty() } ?: userId.value return displayName?.takeIf { it.isNotEmpty() } ?: userId.value
} }

View file

@ -28,11 +28,26 @@ import kotlin.time.Duration
* Can be retrieved from [RoomListService] methods. * Can be retrieved from [RoomListService] methods.
*/ */
interface RoomList { interface RoomList {
/**
* The loading state of the room list.
*/
sealed interface LoadingState { sealed interface LoadingState {
data object NotLoaded : LoadingState data object NotLoaded : LoadingState
data class Loaded(val numberOfRooms: Int) : LoadingState data class Loaded(val numberOfRooms: Int) : LoadingState
} }
/**
* The source of the room list data.
* All: all rooms except invites.
* Invites: only invites.
*
* To apply some dynamic filtering on top of that, use [DynamicRoomList].
*/
enum class Source {
All,
Invites,
}
/** /**
* The list of room summaries as a flow. * The list of room summaries as a flow.
*/ */

View file

@ -18,38 +18,68 @@ package io.element.android.libraries.matrix.api.roomlist
sealed interface RoomListFilter { sealed interface RoomListFilter {
companion object { companion object {
/**
* Create a filter that matches all the given filters.
*/
fun all(vararg filters: RoomListFilter): RoomListFilter { fun all(vararg filters: RoomListFilter): RoomListFilter {
return All(filters.toList()) return All(filters.toList())
} }
/**
* Create a filter that matches any of the given filters.
*/
fun any(vararg filters: RoomListFilter): RoomListFilter { fun any(vararg filters: RoomListFilter): RoomListFilter {
return Any(filters.toList()) return Any(filters.toList())
} }
} }
/**
* A filter that matches all the given filters.
*/
data class All( data class All(
val filters: List<RoomListFilter> val filters: List<RoomListFilter>
) : RoomListFilter ) : RoomListFilter
/**
* A filter that matches any of the given filters.
*/
data class Any( data class Any(
val filters: List<RoomListFilter> val filters: List<RoomListFilter>
) : RoomListFilter ) : RoomListFilter
/**
* A filter that matches rooms that are not left.
*/
data object NonLeft : RoomListFilter data object NonLeft : RoomListFilter
/**
* A filter that matches rooms that are unread.
*/
data object Unread : RoomListFilter data object Unread : RoomListFilter
/**
* A filter that matches either Group or People rooms.
*/
sealed interface Category : RoomListFilter { sealed interface Category : RoomListFilter {
data object Group : Category data object Group : Category
data object People : Category data object People : Category
} }
/**
* A filter that matches no room.
*/
data object None : RoomListFilter data object None : RoomListFilter
/**
* A filter that matches rooms with a name using a normalized match.
*/
data class NormalizedMatchRoomName( data class NormalizedMatchRoomName(
val pattern: String val pattern: String
) : RoomListFilter ) : RoomListFilter
/**
* A filter that matches rooms with a name using a fuzzy match.
*/
data class FuzzyMatchRoomName( data class FuzzyMatchRoomName(
val pattern: String val pattern: String
) : RoomListFilter ) : RoomListFilter

View file

@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.api.roomlist package io.element.android.libraries.matrix.api.roomlist
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
/** /**
@ -39,6 +40,20 @@ interface RoomListService {
data object Hide : SyncIndicator data object Hide : SyncIndicator
} }
/**
* Creates a room list that can be used to load more rooms and filter them dynamically.
* @param coroutineScope the scope to use for the room list. When the scope will be closed, the room list will be closed too.
* @param pageSize the number of rooms to load at once.
* @param initialFilter the initial filter to apply to the rooms.
* @param source the source of the rooms, either all rooms or invites.
*/
fun createRoomList(
coroutineScope: CoroutineScope,
pageSize: Int,
initialFilter: RoomListFilter,
source: RoomList.Source,
): DynamicRoomList
/** /**
* returns a [DynamicRoomList] object of all rooms we want to display. * returns a [DynamicRoomList] object of all rooms we want to display.
* This will exclude some rooms like the invites, or spaces. * This will exclude some rooms like the invites, or spaces.

View file

@ -24,5 +24,5 @@ import kotlinx.parcelize.Parcelize
data class MatrixUser( data class MatrixUser(
val userId: UserId, val userId: UserId,
val displayName: String? = null, val displayName: String? = null,
val avatarUrl: String? = null val avatarUrl: String? = null,
) : Parcelable ) : Parcelable

View file

@ -197,10 +197,10 @@ class RustMatrixClient(
RustRoomListService( RustRoomListService(
innerRoomListService = innerRoomListService, innerRoomListService = innerRoomListService,
sessionCoroutineScope = sessionCoroutineScope, sessionCoroutineScope = sessionCoroutineScope,
sessionDispatcher = sessionDispatcher,
roomListFactory = RoomListFactory( roomListFactory = RoomListFactory(
innerRoomListService = innerRoomListService, innerRoomListService = innerRoomListService,
coroutineScope = sessionCoroutineScope, sessionCoroutineScope = sessionCoroutineScope,
dispatcher = sessionDispatcher,
), ),
) )

View file

@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.impl.room.member
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
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import uniffi.matrix_sdk.RoomMemberRole
import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState
import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember
@ -33,9 +34,17 @@ object RoomMemberMapper {
it.powerLevel(), it.powerLevel(),
it.normalizedPowerLevel(), it.normalizedPowerLevel(),
it.isIgnored(), it.isIgnored(),
mapRole(it.suggestedRoleForPowerLevel())
) )
} }
fun mapRole(role: RoomMemberRole): RoomMember.Role =
when (role) {
RoomMemberRole.ADMINISTRATOR -> RoomMember.Role.ADMIN
RoomMemberRole.MODERATOR -> RoomMember.Role.MODERATOR
RoomMemberRole.USER -> RoomMember.Role.USER
}
fun mapMembership(membershipState: RustMembershipState): RoomMembershipState = fun mapMembership(membershipState: RustMembershipState): RoomMembershipState =
when (membershipState) { when (membershipState) {
RustMembershipState.BAN -> RoomMembershipState.BAN RustMembershipState.BAN -> RoomMembershipState.BAN

View file

@ -20,7 +20,6 @@ import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList
import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -30,13 +29,14 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.matrix.rustcomponents.sdk.RoomListLoadingState import org.matrix.rustcomponents.sdk.RoomListLoadingState
import org.matrix.rustcomponents.sdk.RoomListService
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import org.matrix.rustcomponents.sdk.RoomList as InnerRoomList import org.matrix.rustcomponents.sdk.RoomList as InnerRoomList
import org.matrix.rustcomponents.sdk.RoomListService as InnerRoomListService
internal class RoomListFactory( internal class RoomListFactory(
private val innerRoomListService: InnerRoomListService, private val innerRoomListService: RoomListService,
private val coroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
private val dispatcher: CoroutineDispatcher,
private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(), private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
) { ) {
/** /**
@ -44,18 +44,21 @@ internal class RoomListFactory(
*/ */
fun createRoomList( fun createRoomList(
pageSize: Int, pageSize: Int,
coroutineScope: CoroutineScope = sessionCoroutineScope,
coroutineContext: CoroutineContext = EmptyCoroutineContext,
initialFilter: RoomListFilter = RoomListFilter.all(), initialFilter: RoomListFilter = RoomListFilter.all(),
innerProvider: suspend () -> InnerRoomList innerProvider: suspend () -> InnerRoomList
): DynamicRoomList { ): DynamicRoomList {
val loadingStateFlow: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded) val loadingStateFlow: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded)
val summariesFlow = MutableStateFlow<List<RoomSummary>>(emptyList()) val summariesFlow = MutableStateFlow<List<RoomSummary>>(emptyList())
val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, dispatcher, roomSummaryDetailsFactory) val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryDetailsFactory)
// Makes sure we don't miss any events // Makes sure we don't miss any events
val dynamicEvents = MutableSharedFlow<RoomListDynamicEvents>(replay = 100) val dynamicEvents = MutableSharedFlow<RoomListDynamicEvents>(replay = 100)
val currentFilter = MutableStateFlow(initialFilter) val currentFilter = MutableStateFlow(initialFilter)
val loadedPages = MutableStateFlow(1) val loadedPages = MutableStateFlow(1)
var innerRoomList: InnerRoomList? = null var innerRoomList: InnerRoomList? = null
coroutineScope.launch(dispatcher) {
coroutineScope.launch(coroutineContext) {
innerRoomList = innerProvider() innerRoomList = innerProvider()
innerRoomList?.let { innerRoomList -> innerRoomList?.let { innerRoomList ->
innerRoomList.entriesFlow( innerRoomList.entriesFlow(

View file

@ -17,7 +17,6 @@
package io.element.android.libraries.matrix.impl.roomlist package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@ -28,11 +27,12 @@ import org.matrix.rustcomponents.sdk.RoomListServiceInterface
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import kotlin.coroutines.CoroutineContext
class RoomSummaryListProcessor( class RoomSummaryListProcessor(
private val roomSummaries: MutableStateFlow<List<RoomSummary>>, private val roomSummaries: MutableStateFlow<List<RoomSummary>>,
private val roomListService: RoomListServiceInterface, private val roomListService: RoomListServiceInterface,
private val dispatcher: CoroutineDispatcher, private val coroutineContext: CoroutineContext,
private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(), private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
) { ) {
private val roomSummariesByIdentifier = HashMap<String, RoomSummary>() private val roomSummariesByIdentifier = HashMap<String, RoomSummary>()
@ -130,7 +130,7 @@ class RoomSummaryListProcessor(
return builtRoomSummary return builtRoomSummary
} }
private suspend fun updateRoomSummaries(block: suspend MutableList<RoomSummary>.() -> Unit) = withContext(dispatcher) { private suspend fun updateRoomSummaries(block: suspend MutableList<RoomSummary>.() -> Unit) = withContext(coroutineContext) {
mutex.withLock { mutex.withLock {
val mutableRoomSummaries = roomSummaries.value.toMutableList() val mutableRoomSummaries = roomSummaries.value.toMutableList()
block(mutableRoomSummaries) block(mutableRoomSummaries)

View file

@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -42,10 +43,31 @@ private const val DEFAULT_PAGE_SIZE = 20
internal class RustRoomListService( internal class RustRoomListService(
private val innerRoomListService: InnerRustRoomListService, private val innerRoomListService: InnerRustRoomListService,
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
roomListFactory: RoomListFactory, private val sessionDispatcher: CoroutineDispatcher,
private val roomListFactory: RoomListFactory,
) : RoomListService { ) : RoomListService {
override fun createRoomList(
coroutineScope: CoroutineScope,
pageSize: Int,
initialFilter: RoomListFilter,
source: RoomList.Source
): DynamicRoomList {
return roomListFactory.createRoomList(
pageSize = pageSize,
initialFilter = initialFilter,
coroutineScope = coroutineScope,
coroutineContext = sessionDispatcher,
) {
when (source) {
RoomList.Source.All -> innerRoomListService.allRooms()
RoomList.Source.Invites -> innerRoomListService.invites()
}
}
}
override val allRooms: DynamicRoomList = roomListFactory.createRoomList( override val allRooms: DynamicRoomList = roomListFactory.createRoomList(
pageSize = DEFAULT_PAGE_SIZE, pageSize = DEFAULT_PAGE_SIZE,
coroutineContext = sessionDispatcher,
initialFilter = RoomListFilter.all(RoomListFilter.NonLeft), initialFilter = RoomListFilter.all(RoomListFilter.NonLeft),
) { ) {
innerRoomListService.allRooms() innerRoomListService.allRooms()
@ -53,6 +75,7 @@ internal class RustRoomListService(
override val invites: RoomList = roomListFactory.createRoomList( override val invites: RoomList = roomListFactory.createRoomList(
pageSize = Int.MAX_VALUE, pageSize = Int.MAX_VALUE,
coroutineContext = sessionDispatcher,
) { ) {
innerRoomListService.invites() innerRoomListService.invites()
} }

View file

@ -34,6 +34,7 @@ import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomMember import org.matrix.rustcomponents.sdk.RoomMember
import org.matrix.rustcomponents.sdk.RoomMembersIterator import org.matrix.rustcomponents.sdk.RoomMembersIterator
import uniffi.matrix_sdk.RoomMemberRole
class RoomMemberListFetcherTest { class RoomMemberListFetcherTest {
@Test @Test
@ -268,6 +269,7 @@ class FakeRustRoomMember(
private val membership: MembershipState = MembershipState.JOIN, private val membership: MembershipState = MembershipState.JOIN,
private val isNameAmbiguous: Boolean = false, private val isNameAmbiguous: Boolean = false,
private val powerLevel: Long = 0L, private val powerLevel: Long = 0L,
private val role: RoomMemberRole = RoomMemberRole.USER,
) : RoomMember(NoPointer) { ) : RoomMember(NoPointer) {
override fun userId(): String { override fun userId(): String {
return userId.value return userId.value
@ -300,4 +302,8 @@ class FakeRustRoomMember(
override fun isIgnored(): Boolean { override fun isIgnored(): Boolean {
return false return false
} }
override fun suggestedRoleForPowerLevel(): RoomMemberRole {
return role
}
} }

View file

@ -158,7 +158,7 @@ class RoomSummaryListProcessorTests {
private fun TestScope.createProcessor() = RoomSummaryListProcessor( private fun TestScope.createProcessor() = RoomSummaryListProcessor(
summaries, summaries,
fakeRoomListService, fakeRoomListService,
dispatcher = StandardTestDispatcher(testScheduler), coroutineContext = StandardTestDispatcher(testScheduler),
roomSummaryDetailsFactory = RoomSummaryDetailsFactory(), roomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
) )

View file

@ -103,6 +103,10 @@ class FakeEncryptionService : EncryptionService {
backupStateStateFlow.emit(state) backupStateStateFlow.emit(state)
} }
suspend fun emitRecoveryState(state: RecoveryState) {
recoveryStateStateFlow.emit(state)
}
suspend fun emitEnableRecoveryProgress(state: EnableRecoveryProgress) { suspend fun emitEnableRecoveryProgress(state: EnableRecoveryProgress) {
enableRecoveryProgressStateFlow.emit(state) enableRecoveryProgressStateFlow.emit(state)
} }

View file

@ -29,6 +29,7 @@ fun aRoomMember(
powerLevel: Long = 0L, powerLevel: Long = 0L,
normalizedPowerLevel: Long = 0L, normalizedPowerLevel: Long = 0L,
isIgnored: Boolean = false, isIgnored: Boolean = false,
role: RoomMember.Role = RoomMember.Role.USER,
) = RoomMember( ) = RoomMember(
userId = userId, userId = userId,
displayName = displayName, displayName = displayName,
@ -38,4 +39,5 @@ fun aRoomMember(
powerLevel = powerLevel, powerLevel = powerLevel,
normalizedPowerLevel = normalizedPowerLevel, normalizedPowerLevel = normalizedPowerLevel,
isIgnored = isIgnored, isIgnored = isIgnored,
role = role,
) )

View file

@ -53,6 +53,10 @@ fun aRoomSummaryFilled(
) )
) )
fun aRoomSummaryFilled(
details: RoomSummaryDetails = aRoomSummaryDetails(),
) = RoomSummary.Filled(details)
fun aRoomSummaryDetails( fun aRoomSummaryDetails(
roomId: RoomId = A_ROOM_ID, roomId: RoomId = A_ROOM_ID,
name: String = A_ROOM_NAME, name: String = A_ROOM_NAME,

View file

@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -59,13 +60,20 @@ class FakeRoomListService : RoomListService {
var latestSlidingSyncRange: IntRange? = null var latestSlidingSyncRange: IntRange? = null
private set private set
override val allRooms: DynamicRoomList = SimplePagedRoomList( override fun createRoomList(coroutineScope: CoroutineScope, pageSize: Int, initialFilter: RoomListFilter, source: RoomList.Source): DynamicRoomList {
return when (source) {
RoomList.Source.All -> allRooms
RoomList.Source.Invites -> invites
}
}
override val allRooms = SimplePagedRoomList(
allRoomSummariesFlow, allRoomSummariesFlow,
allRoomsLoadingStateFlow, allRoomsLoadingStateFlow,
MutableStateFlow(RoomListFilter.all()) MutableStateFlow(RoomListFilter.all())
) )
override val invites: RoomList = SimplePagedRoomList( override val invites = SimplePagedRoomList(
inviteRoomSummariesFlow, inviteRoomSummariesFlow,
inviteRoomsLoadingStateFlow, inviteRoomsLoadingStateFlow,
MutableStateFlow(RoomListFilter.all()) MutableStateFlow(RoomListFilter.all())

View file

@ -25,7 +25,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.getAndUpdate
data class SimplePagedRoomList( data class SimplePagedRoomList(
override val summaries: StateFlow<List<RoomSummary>>, override val summaries: MutableStateFlow<List<RoomSummary>>,
override val loadingState: StateFlow<RoomList.LoadingState>, override val loadingState: StateFlow<RoomList.LoadingState>,
override val currentFilter: MutableStateFlow<RoomListFilter> override val currentFilter: MutableStateFlow<RoomListFilter>
) : DynamicRoomList { ) : DynamicRoomList {

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.ui.room
import io.element.android.libraries.matrix.api.room.RoomMember
/**
* Returns the name value to use when sorting room members.
*
* If the display name is not null and not empty, it is returned.
* Otherwise, the user ID is returned without the initial "@".
*/
fun RoomMember.sortingName(): String {
return displayName?.takeIf { it.isNotEmpty() } ?: userId.value.drop(1)
}

View file

@ -74,4 +74,14 @@ object TestTags {
val dialogPositive = TestTag("dialog-positive") val dialogPositive = TestTag("dialog-positive")
val dialogNegative = TestTag("dialog-negative") val dialogNegative = TestTag("dialog-negative")
val dialogNeutral = TestTag("dialog-neutral") val dialogNeutral = TestTag("dialog-neutral")
/**
* Floating Action Button.
*/
val floatingActionButton = TestTag("floating-action-button")
/**
* Timeline item.
*/
val timelineItemSenderInfo = TestTag("timeline_item-sender_info")
} }

View file

@ -85,6 +85,8 @@ fun Project.setupKover() {
"*Presenter\$present\$*", "*Presenter\$present\$*",
// Forked from compose // Forked from compose
"io.element.android.libraries.designsystem.theme.components.bottomsheet.*", "io.element.android.libraries.designsystem.theme.components.bottomsheet.*",
// Test presenter
"io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter",
) )
annotatedBy( annotatedBy(
"androidx.compose.ui.tooling.preview.Preview", "androidx.compose.ui.tooling.preview.Preview",

View file

@ -30,6 +30,7 @@ import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
import io.element.android.features.roomlist.impl.migration.SharedPrefsMigrationScreenStore import io.element.android.features.roomlist.impl.migration.SharedPrefsMigrationScreenStore
import io.element.android.features.roomlist.impl.search.RoomListSearchPresenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.dateformatter.impl.DateFormatters import io.element.android.libraries.dateformatter.impl.DateFormatters
import io.element.android.libraries.dateformatter.impl.DefaultLastMessageTimestampFormatter import io.element.android.libraries.dateformatter.impl.DefaultLastMessageTimestampFormatter
@ -71,6 +72,21 @@ class RoomListScreen(
private val featureFlagService = DefaultFeatureFlagService( private val featureFlagService = DefaultFeatureFlagService(
providers = setOf(StaticFeatureFlagProvider()) providers = setOf(StaticFeatureFlagProvider())
) )
private val roomListRoomSummaryFactory = RoomListRoomSummaryFactory(
lastMessageTimestampFormatter = DefaultLastMessageTimestampFormatter(
localDateTimeProvider = dateTimeProvider,
dateFormatters = dateFormatters
),
roomLastMessageFormatter = DefaultRoomLastMessageFormatter(
sp = stringProvider,
roomMembershipContentFormatter = RoomMembershipContentFormatter(
matrixClient = matrixClient,
sp = stringProvider
),
profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider),
stateContentFormatter = StateContentFormatter(stringProvider),
),
)
private val presenter = RoomListPresenter( private val presenter = RoomListPresenter(
client = matrixClient, client = matrixClient,
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
@ -80,26 +96,11 @@ class RoomListScreen(
leaveRoomPresenter = LeaveRoomPresenterImpl(matrixClient, RoomMembershipObserver(), coroutineDispatchers), leaveRoomPresenter = LeaveRoomPresenterImpl(matrixClient, RoomMembershipObserver(), coroutineDispatchers),
roomListDataSource = RoomListDataSource( roomListDataSource = RoomListDataSource(
roomListService = matrixClient.roomListService, roomListService = matrixClient.roomListService,
roomListRoomSummaryFactory = RoomListRoomSummaryFactory( roomListRoomSummaryFactory = roomListRoomSummaryFactory,
lastMessageTimestampFormatter = DefaultLastMessageTimestampFormatter(
localDateTimeProvider = dateTimeProvider,
dateFormatters = dateFormatters
),
roomLastMessageFormatter = DefaultRoomLastMessageFormatter(
sp = stringProvider,
roomMembershipContentFormatter = RoomMembershipContentFormatter(
matrixClient = matrixClient,
sp = stringProvider
),
profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider),
stateContentFormatter = StateContentFormatter(stringProvider),
),
),
coroutineDispatchers = coroutineDispatchers, coroutineDispatchers = coroutineDispatchers,
notificationSettingsService = matrixClient.notificationSettingsService(), notificationSettingsService = matrixClient.notificationSettingsService(),
appScope = Singleton.appScope appScope = Singleton.appScope
), ),
encryptionService = encryptionService,
indicatorService = DefaultIndicatorService( indicatorService = DefaultIndicatorService(
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
encryptionService = encryptionService, encryptionService = encryptionService,
@ -110,6 +111,11 @@ class RoomListScreen(
matrixClient = matrixClient, matrixClient = matrixClient,
migrationScreenStore = SharedPrefsMigrationScreenStore(context.getSharedPreferences("migration", Context.MODE_PRIVATE)) migrationScreenStore = SharedPrefsMigrationScreenStore(context.getSharedPreferences("migration", Context.MODE_PRIVATE))
), ),
searchPresenter = RoomListSearchPresenter(
roomListService = matrixClient.roomListService,
roomSummaryFactory = roomListRoomSummaryFactory,
coroutineDispatchers = coroutineDispatchers,
),
sessionPreferencesStore = DefaultSessionPreferencesStore( sessionPreferencesStore = DefaultSessionPreferencesStore(
context = context, context = context,
sessionId = matrixClient.sessionId, sessionId = matrixClient.sessionId,

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:eba9e3f6b232813ccb2c42eb9ede3b594769619e4ce8f13b9d43c53688da070b oid sha256:76bd5b2b4e4277cd9f87c8d39ec7490cde4d9de0f1f99cde8cd9061c19a9f34f
size 38958 size 47105

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:839119e003cdbccea31c8a19ee50248890a3bc042a0f820163ce5fdd7411f193 oid sha256:da396a8bdd3b9124c0b896e0dd79e7835091041b0544041421bf25b7f2edcaef
size 25282 size 25960

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:372179764b951cb9074a0e573241402ec2137045f26d9ffea1d567b93377c624 oid sha256:02874e8bf08b040dc154b81e579c602e023abd99b2af8732a9e322921042ac8d
size 38233 size 46597

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6ac113aa7a641c20d9c725078509de96e6878adf95e7ff456d77c309f263a16f oid sha256:3f764f7ca578de87f854d4bd090bc642a851f4ce85deee91c0ca77e3d4a5f582
size 24936 size 25653

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5f8802121f779f48dd556a9a3a08e6ac1a63e86d5695f6f8c133fbb346d76ef6 oid sha256:13dadbd502163a9bfe81cb1e67f9aa0933c4b7f4fd3c8f731e930a28709485d2
size 89779 size 51948

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2523a28a889fd1739fe1e0c94edce5d86a5ebcbe6c3973a3c49fbb8fb8a19f79 oid sha256:64c4eb481f40871925405ae317cb80927caf31cb552a8aa9549bfb5658ca91e4
size 55617 size 137589

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:13dadbd502163a9bfe81cb1e67f9aa0933c4b7f4fd3c8f731e930a28709485d2 oid sha256:af42c9891a6670cdde07cf054e139ce1f83e877bb68f60463fcad6dd28d8e049
size 51948 size 6867

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7f313e09baa93bb4ff9eda9a3126b2cf49867fac3c1d53f11ec027b4dad7fbff oid sha256:3764d8bd7dc2783a8af43aad65a217d7e533ed17c4d4367b7994470bf35b62b0
size 4909 size 4462

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b2dc223ad7ca556a74aaff1e0ec6a94c10b951457c7b9614804a4c477c4e6e8e oid sha256:3764d8bd7dc2783a8af43aad65a217d7e533ed17c4d4367b7994470bf35b62b0
size 29974 size 4462

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3764d8bd7dc2783a8af43aad65a217d7e533ed17c4d4367b7994470bf35b62b0 oid sha256:5f8802121f779f48dd556a9a3a08e6ac1a63e86d5695f6f8c133fbb346d76ef6
size 4462 size 89779

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3764d8bd7dc2783a8af43aad65a217d7e533ed17c4d4367b7994470bf35b62b0 oid sha256:2523a28a889fd1739fe1e0c94edce5d86a5ebcbe6c3973a3c49fbb8fb8a19f79
size 4462 size 55617

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7cdd93c157565f7f8d3fa23a43c30e02531dab40578a315c5d0d10a99e74f259 oid sha256:0a00391ef16a762a14dd7e348ce97b500ca1b42a32ee3ff926a10865f46cd06c
size 91394 size 53590

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2b25e023153dd094299a0c794d5e488bdc6fc0c478abbfbce7337e52b233715d oid sha256:a7bf47b0a25c455b108d7b3585a42849f03c6652af73a175fe2147cb1ad62a66
size 57463 size 161125

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0a00391ef16a762a14dd7e348ce97b500ca1b42a32ee3ff926a10865f46cd06c oid sha256:6e95dac6e75d3f615ccf4d8e98f1780d8aeace1cddf0b33bba8b485860d3216b
size 53590 size 6688

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6e5a9aba28b5a7dfa41b8ca47e74ebe629f717d9bd51836d71c31adb436083af oid sha256:3764d8bd7dc2783a8af43aad65a217d7e533ed17c4d4367b7994470bf35b62b0
size 4861 size 4462

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:199f1cf052e17b08810d026921b53ee35b1a942f9b661beb2286e6891843898c oid sha256:3764d8bd7dc2783a8af43aad65a217d7e533ed17c4d4367b7994470bf35b62b0
size 29867 size 4462

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3764d8bd7dc2783a8af43aad65a217d7e533ed17c4d4367b7994470bf35b62b0 oid sha256:7cdd93c157565f7f8d3fa23a43c30e02531dab40578a315c5d0d10a99e74f259
size 4462 size 91394

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3764d8bd7dc2783a8af43aad65a217d7e533ed17c4d4367b7994470bf35b62b0 oid sha256:2b25e023153dd094299a0c794d5e488bdc6fc0c478abbfbce7337e52b233715d
size 4462 size 57463