Merge branch 'develop' into feature/fga/room_list_filters
This commit is contained in:
2
.github/workflows/maestro.yml
vendored
2
.github/workflows/maestro.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_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'
|
||||
with:
|
||||
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
|
||||
|
||||
1
changelog.d/+improve-accessibility-in-timeline.bugfix
Normal file
1
changelog.d/+improve-accessibility-in-timeline.bugfix
Normal 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
3
changelog.d/2256.feature
Normal file
@@ -0,0 +1,3 @@
|
||||
Add moderation to rooms:
|
||||
|
||||
- Sort member in room member list by powerlevel, display their roles.
|
||||
@@ -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.matrix.api.MatrixClient
|
||||
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.roomlist.RoomSummary
|
||||
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.FakeMatrixClient
|
||||
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.roomlist.FakeRoomListService
|
||||
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
|
||||
@@ -431,7 +431,7 @@ class InviteListPresenterTests {
|
||||
avatarUrl = null,
|
||||
isDirect = false,
|
||||
lastMessage = null,
|
||||
inviter = RoomMember(
|
||||
inviter = aRoomMember(
|
||||
userId = A_USER_ID,
|
||||
displayName = A_USER_NAME,
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
@@ -458,7 +458,7 @@ class InviteListPresenterTests {
|
||||
avatarUrl = null,
|
||||
isDirect = true,
|
||||
lastMessage = null,
|
||||
inviter = RoomMember(
|
||||
inviter = aRoomMember(
|
||||
userId = A_USER_ID,
|
||||
displayName = A_USER_NAME,
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
|
||||
@@ -22,6 +22,11 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.location.impl"
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anvil {
|
||||
@@ -51,11 +56,14 @@ dependencies {
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.testtags)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.features.messages.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
|
||||
@@ -24,78 +24,47 @@ private const val APP_NAME = "ApplicationName"
|
||||
class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
|
||||
override val values: Sequence<ShowLocationState>
|
||||
get() = sequenceOf(
|
||||
ShowLocationState(
|
||||
ShowLocationState.Dialog.None,
|
||||
Location(1.23, 2.34, 4f),
|
||||
description = null,
|
||||
hasLocationPermission = false,
|
||||
isTrackMyLocation = false,
|
||||
appName = APP_NAME,
|
||||
eventSink = {},
|
||||
aShowLocationState(),
|
||||
aShowLocationState(
|
||||
permissionDialog = ShowLocationState.Dialog.PermissionDenied,
|
||||
),
|
||||
ShowLocationState(
|
||||
ShowLocationState.Dialog.PermissionDenied,
|
||||
Location(1.23, 2.34, 4f),
|
||||
description = null,
|
||||
hasLocationPermission = false,
|
||||
isTrackMyLocation = false,
|
||||
appName = APP_NAME,
|
||||
eventSink = {},
|
||||
aShowLocationState(
|
||||
permissionDialog = ShowLocationState.Dialog.PermissionRationale,
|
||||
),
|
||||
ShowLocationState(
|
||||
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,
|
||||
aShowLocationState(
|
||||
hasLocationPermission = true,
|
||||
isTrackMyLocation = false,
|
||||
appName = APP_NAME,
|
||||
eventSink = {},
|
||||
),
|
||||
ShowLocationState(
|
||||
ShowLocationState.Dialog.None,
|
||||
Location(1.23, 2.34, 4f),
|
||||
description = null,
|
||||
aShowLocationState(
|
||||
hasLocationPermission = true,
|
||||
isTrackMyLocation = true,
|
||||
appName = APP_NAME,
|
||||
eventSink = {},
|
||||
),
|
||||
ShowLocationState(
|
||||
ShowLocationState.Dialog.None,
|
||||
Location(1.23, 2.34, 4f),
|
||||
aShowLocationState(
|
||||
description = "My favourite place!",
|
||||
hasLocationPermission = false,
|
||||
isTrackMyLocation = false,
|
||||
appName = APP_NAME,
|
||||
eventSink = {},
|
||||
),
|
||||
ShowLocationState(
|
||||
ShowLocationState.Dialog.None,
|
||||
Location(1.23, 2.34, 4f),
|
||||
aShowLocationState(
|
||||
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(
|
||||
ShowLocationState.Dialog.None,
|
||||
Location(1.23, 2.34, 4f),
|
||||
aShowLocationState(
|
||||
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!",
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -120,10 +120,14 @@ fun ShowLocationView(
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
BackButton(onClick = onBackPressed)
|
||||
BackButton(
|
||||
onClick = onBackPressed,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { state.eventSink(ShowLocationEvents.Share) }) {
|
||||
IconButton(
|
||||
onClick = { state.eventSink(ShowLocationEvents.Share) }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.ShareAndroid(),
|
||||
contentDescription = stringResource(CommonStrings.action_share),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,7 @@ internal fun MentionSuggestionsPickerView_Preview() {
|
||||
powerLevel = 0L,
|
||||
normalizedPowerLevel = 0L,
|
||||
isIgnored = false,
|
||||
role = RoomMember.Role.USER,
|
||||
)
|
||||
MentionSuggestionsPickerView(
|
||||
roomId = RoomId("!room:matrix.org"),
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -45,8 +47,8 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
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.TypingNotificationView
|
||||
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.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
|
||||
@@ -99,7 +100,13 @@ fun TimelineView(
|
||||
state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex))
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
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")
|
||||
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
|
||||
val alpha by alphaAnimation(label = "alpha for timeline")
|
||||
|
||||
Box(modifier = modifier.alpha(alpha)) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = lazyListState,
|
||||
reverseLayout = true,
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
item {
|
||||
TypingNotificationView(state = typingNotificationState)
|
||||
}
|
||||
items(
|
||||
items = state.timelineItems,
|
||||
contentType = { timelineItem -> timelineItem.contentType() },
|
||||
key = { timelineItem -> timelineItem.identifier() },
|
||||
) { timelineItem ->
|
||||
TimelineItemRow(
|
||||
timelineItem = timelineItem,
|
||||
timelineRoomInfo = state.timelineRoomInfo,
|
||||
renderReadReceipts = state.renderReadReceipts,
|
||||
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true &&
|
||||
state.timelineItems.first().identifier() == timelineItem.identifier(),
|
||||
highlightedItem = state.highlightedEventId?.value,
|
||||
onClick = onMessageClicked,
|
||||
onLongClick = onMessageLongClicked,
|
||||
onUserDataClick = onUserDataClicked,
|
||||
inReplyToClick = ::inReplyToClicked,
|
||||
onReactionClick = onReactionClicked,
|
||||
onReactionLongClick = onReactionLongClicked,
|
||||
onMoreReactionsClick = onMoreReactionsClicked,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
sessionState = state.sessionState,
|
||||
eventSink = state.eventSink,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
)
|
||||
}
|
||||
if (state.paginationState.hasMoreToLoadBackwards) {
|
||||
// Do not use key parameter to avoid wrong positioning
|
||||
item(contentType = "TimelineLoadingMoreIndicator") {
|
||||
TimelineLoadingMoreIndicator()
|
||||
LaunchedEffect(Unit) {
|
||||
onReachedLoadMore()
|
||||
AnimatedVisibility(visible = true, enter = fadeIn()) {
|
||||
Box(modifier) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = lazyListState,
|
||||
reverseLayout = useReverseLayout,
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
item {
|
||||
TypingNotificationView(state = typingNotificationState)
|
||||
}
|
||||
items(
|
||||
items = state.timelineItems,
|
||||
contentType = { timelineItem -> timelineItem.contentType() },
|
||||
key = { timelineItem -> timelineItem.identifier() },
|
||||
) { timelineItem ->
|
||||
TimelineItemRow(
|
||||
timelineItem = timelineItem,
|
||||
timelineRoomInfo = state.timelineRoomInfo,
|
||||
renderReadReceipts = state.renderReadReceipts,
|
||||
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true &&
|
||||
state.timelineItems.first().identifier() == timelineItem.identifier(),
|
||||
highlightedItem = state.highlightedEventId?.value,
|
||||
onClick = onMessageClicked,
|
||||
onLongClick = onMessageLongClicked,
|
||||
onUserDataClick = onUserDataClicked,
|
||||
inReplyToClick = ::inReplyToClicked,
|
||||
onReactionClick = onReactionClicked,
|
||||
onReactionLongClick = onReactionLongClicked,
|
||||
onMoreReactionsClick = onMoreReactionsClicked,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
sessionState = state.sessionState,
|
||||
eventSink = state.eventSink,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
)
|
||||
}
|
||||
if (state.paginationState.hasMoreToLoadBackwards) {
|
||||
// Do not use key parameter to avoid wrong positioning
|
||||
item(contentType = "TimelineLoadingMoreIndicator") {
|
||||
TimelineLoadingMoreIndicator()
|
||||
LaunchedEffect(Unit) {
|
||||
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(
|
||||
isTimelineEmpty = state.timelineItems.isEmpty(),
|
||||
lazyListState = lazyListState,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
newEventState = state.newEventState,
|
||||
onScrollFinishedAt = ::onScrollFinishedAt
|
||||
)
|
||||
TimelineScrollHelper(
|
||||
isTimelineEmpty = state.timelineItems.isEmpty(),
|
||||
lazyListState = lazyListState,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
newEventState = state.newEventState,
|
||||
onScrollFinishedAt = ::onScrollFinishedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.ViewConfiguration
|
||||
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.style.TextAlign
|
||||
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.room.Mention
|
||||
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 kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
@@ -256,6 +263,7 @@ private fun SwipeSensitivity(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun TimelineItemEventRowContent(
|
||||
event: TimelineItem.Event,
|
||||
@@ -305,6 +313,11 @@ private fun TimelineItemEventRowContent(
|
||||
.padding(horizontal = 16.dp)
|
||||
.zIndex(1f)
|
||||
.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(
|
||||
event: TimelineItem.Event,
|
||||
onMessageLongClick: () -> Unit,
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
inReplyToClick: () -> Unit,
|
||||
onTimestampClicked: () -> Unit,
|
||||
onMentionClicked: (Mention) -> Unit,
|
||||
@@ -445,6 +459,7 @@ private fun MessageEventBubbleContent(
|
||||
text = stringResource(CommonStrings.common_thread),
|
||||
style = ElementTheme.typography.fontBodyXsRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
modifier = Modifier.clearAndSetSemantics { }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -580,7 +595,8 @@ private fun MessageEventBubbleContent(
|
||||
modifier = Modifier
|
||||
.padding(top = topPadding, start = 8.dp, end = 8.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) {
|
||||
@@ -611,7 +627,9 @@ private fun MessageEventBubbleContent(
|
||||
timestampPosition = timestampPosition,
|
||||
inReplyToDetails = event.inReplyTo,
|
||||
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))
|
||||
}
|
||||
val a11InReplyToText = stringResource(CommonStrings.common_in_reply_to, senderName)
|
||||
Column(verticalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = a11InReplyToText
|
||||
},
|
||||
text = senderName,
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
textAlign = TextAlign.Start,
|
||||
|
||||
@@ -18,6 +18,9 @@ package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
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 io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
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.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun TimelineItemImageView(
|
||||
content: TimelineItemImageContent,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val description = stringResource(CommonStrings.common_image)
|
||||
TimelineItemAspectRatioBox(
|
||||
aspectRatio = content.aspectRatio,
|
||||
modifier = modifier,
|
||||
modifier = modifier.semantics { contentDescription = description },
|
||||
) {
|
||||
BlurHashAsyncImage(
|
||||
model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
|
||||
|
||||
@@ -23,6 +23,8 @@ import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
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 io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
@@ -48,7 +50,7 @@ fun TimelineItemTextView(
|
||||
val formattedBody = content.formattedBody
|
||||
val body = SpannableString(formattedBody ?: content.body)
|
||||
|
||||
Box(modifier) {
|
||||
Box(modifier.semantics { contentDescription = body.toString() }) {
|
||||
EditorStyledText(
|
||||
text = body,
|
||||
onLinkClickedListener = onLinkClicked,
|
||||
|
||||
@@ -27,6 +27,8 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
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 io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
|
||||
@@ -42,9 +44,10 @@ fun TimelineItemVideoView(
|
||||
content: TimelineItemVideoContent,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val description = stringResource(CommonStrings.common_image)
|
||||
TimelineItemAspectRatioBox(
|
||||
aspectRatio = content.aspectRatio,
|
||||
modifier = modifier,
|
||||
modifier = modifier.semantics { contentDescription = description },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
BlurHashAsyncImage(
|
||||
|
||||
@@ -103,5 +103,6 @@ private fun createDefaultRoomMemberForTyping(userId: UserId): RoomMember {
|
||||
powerLevel = 0,
|
||||
normalizedPowerLevel = 0,
|
||||
isIgnored = false,
|
||||
role = RoomMember.Role.USER,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -98,5 +98,6 @@ internal fun aTypingRoomMember(
|
||||
powerLevel = 0,
|
||||
normalizedPowerLevel = 0,
|
||||
isIgnored = false,
|
||||
role = RoomMember.Role.USER,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.timeline.FakeHtmlConverterProvider
|
||||
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.FakeSendPollResponseAction
|
||||
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
|
||||
@@ -98,6 +99,7 @@ import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
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
|
||||
fun `present - handle action redact`() = runTest {
|
||||
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
|
||||
@@ -683,6 +701,7 @@ class MessagesPresenterTest {
|
||||
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
|
||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||
endPollAction: EndPollAction = FakeEndPollAction(),
|
||||
): MessagesPresenter {
|
||||
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
|
||||
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
|
||||
@@ -721,7 +740,7 @@ class MessagesPresenterTest {
|
||||
encryptionService = FakeEncryptionService(),
|
||||
verificationService = FakeSessionVerificationService(),
|
||||
redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
|
||||
endPollAction = FakeEndPollAction(),
|
||||
endPollAction = endPollAction,
|
||||
sendPollResponseAction = FakeSendPollResponseAction(),
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
)
|
||||
|
||||
@@ -326,8 +326,7 @@ class MessagesViewTest {
|
||||
state = state,
|
||||
onUserDataClicked = callback,
|
||||
)
|
||||
val senderName = (timelineItem as? TimelineItem.Event)?.senderDisplayName.orEmpty()
|
||||
rule.onNodeWithText(senderName).performClick()
|
||||
rule.onNodeWithTag(TestTags.timelineItemSenderInfo.value).performClick()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,10 +87,13 @@ import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import uniffi.wysiwyg_composer.MentionsState
|
||||
import java.io.File
|
||||
|
||||
@Suppress("LargeClass")
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MessageComposerPresenterTest {
|
||||
@get:Rule
|
||||
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
|
||||
fun `present - handle typing notice event`() = runTest {
|
||||
val room = FakeMatrixRoom()
|
||||
|
||||
@@ -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.room.MatrixRoom
|
||||
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_2
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_3
|
||||
@@ -178,7 +176,6 @@ class TypingNotificationPresenterTest {
|
||||
|
||||
@Test
|
||||
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 presenter = createPresenter(matrixRoom = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
@@ -216,27 +213,17 @@ class TypingNotificationPresenterTest {
|
||||
|
||||
private fun createDefaultRoomMember(
|
||||
userId: UserId,
|
||||
) = RoomMember(
|
||||
) = aTypingRoomMember(
|
||||
userId = userId,
|
||||
displayName = null,
|
||||
avatarUrl = null,
|
||||
membership = RoomMembershipState.JOIN,
|
||||
isNameAmbiguous = false,
|
||||
powerLevel = 0,
|
||||
normalizedPowerLevel = 0,
|
||||
isIgnored = false,
|
||||
)
|
||||
|
||||
private fun createKnownRoomMember(
|
||||
userId: UserId,
|
||||
) = RoomMember(
|
||||
) = aTypingRoomMember(
|
||||
userId = userId,
|
||||
displayName = "Alice Doe",
|
||||
avatarUrl = "an_avatar_url",
|
||||
membership = RoomMembershipState.JOIN,
|
||||
isNameAmbiguous = true,
|
||||
powerLevel = 0,
|
||||
normalizedPowerLevel = 0,
|
||||
isIgnored = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.roomdetails.impl"
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anvil {
|
||||
@@ -61,6 +66,7 @@ dependencies {
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.mockk)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.mediaupload.test)
|
||||
testImplementation(projects.libraries.mediapickers.test)
|
||||
@@ -70,6 +76,8 @@ dependencies {
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(projects.features.leaveroom.test)
|
||||
testImplementation(projects.features.createroom.test)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ fun aDmRoomMember(
|
||||
powerLevel: Long = 0,
|
||||
normalizedPowerLevel: Long = powerLevel,
|
||||
isIgnored: Boolean = false,
|
||||
role: RoomMember.Role = RoomMember.Role.USER,
|
||||
) = RoomMember(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
@@ -64,6 +65,7 @@ fun aDmRoomMember(
|
||||
powerLevel = powerLevel,
|
||||
normalizedPowerLevel = normalizedPowerLevel,
|
||||
isIgnored = isIgnored,
|
||||
role = role,
|
||||
)
|
||||
|
||||
fun aRoomDetailsState() = RoomDetailsState(
|
||||
|
||||
@@ -55,7 +55,10 @@ fun BlockUserDialogs(state: RoomMemberDetailsState) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlockConfirmationDialog(onBlockAction: () -> Unit, onDismiss: () -> Unit) {
|
||||
private fun BlockConfirmationDialog(
|
||||
onBlockAction: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
ConfirmationDialog(
|
||||
title = stringResource(R.string.screen_dm_details_block_user),
|
||||
content = stringResource(R.string.screen_dm_details_block_alert_description),
|
||||
@@ -66,7 +69,10 @@ private fun BlockConfirmationDialog(onBlockAction: () -> Unit, onDismiss: () ->
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnblockConfirmationDialog(onUnblockAction: () -> Unit, onDismiss: () -> Unit) {
|
||||
private fun UnblockConfirmationDialog(
|
||||
onUnblockAction: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
ConfirmationDialog(
|
||||
title = stringResource(R.string.screen_dm_details_unblock_user),
|
||||
content = stringResource(R.string.screen_dm_details_unblock_alert_description),
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,9 @@ class RoomMemberListPresenter @Inject constructor(
|
||||
roomMembers = AsyncData.Success(
|
||||
RoomMembers(
|
||||
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(
|
||||
RoomMembers(
|
||||
invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
|
||||
joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(),
|
||||
joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList())
|
||||
.sortedWith(PowerLevelRoomMemberComparator())
|
||||
.toImmutableList(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
|
||||
roomMembers = AsyncData.Success(
|
||||
RoomMembers(
|
||||
invited = persistentListOf(aVictor(), aWalter()),
|
||||
joined = persistentListOf(anAlice(), aBob()),
|
||||
joined = persistentListOf(anAlice(), aBob(), aWalter()),
|
||||
)
|
||||
)
|
||||
),
|
||||
@@ -79,6 +79,7 @@ fun aRoomMember(
|
||||
powerLevel: Long = 0L,
|
||||
normalizedPowerLevel: Long = 0L,
|
||||
isIgnored: Boolean = false,
|
||||
role: RoomMember.Role = RoomMember.Role.USER,
|
||||
) = RoomMember(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
@@ -88,6 +89,7 @@ fun aRoomMember(
|
||||
powerLevel = powerLevel,
|
||||
normalizedPowerLevel = normalizedPowerLevel,
|
||||
isIgnored = isIgnored,
|
||||
role = role,
|
||||
)
|
||||
|
||||
fun aRoomMemberList() = persistentListOf(
|
||||
@@ -103,8 +105,8 @@ fun aRoomMemberList() = persistentListOf(
|
||||
aWalter(),
|
||||
)
|
||||
|
||||
fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice")
|
||||
fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob")
|
||||
fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice", role = RoomMember.Role.ADMIN)
|
||||
fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob", role = RoomMember.Role.MODERATOR)
|
||||
|
||||
fun aVictor() = aRoomMember(UserId("@victor:server.org"), "Victor", membership = RoomMembershipState.INVITE)
|
||||
|
||||
|
||||
@@ -177,14 +177,28 @@ private fun RoomMemberListItem(
|
||||
onClick: () -> Unit,
|
||||
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(
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
matrixUser = MatrixUser(
|
||||
userId = roomMember.userId,
|
||||
displayName = roomMember.displayName,
|
||||
avatarUrl = roomMember.avatarUrl
|
||||
avatarUrl = roomMember.avatarUrl,
|
||||
),
|
||||
avatarSize = AvatarSize.UserListItem,
|
||||
trailingContent = roleText?.let {
|
||||
@Composable {
|
||||
Text(
|
||||
text = it,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,28 +19,38 @@ package io.element.android.features.roomdetails.impl.members.details
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
open class RoomMemberDetailsStateProvider : PreviewParameterProvider<RoomMemberDetailsState> {
|
||||
override val values: Sequence<RoomMemberDetailsState>
|
||||
get() = sequenceOf(
|
||||
aRoomMemberDetailsState(),
|
||||
aRoomMemberDetailsState().copy(userName = null),
|
||||
aRoomMemberDetailsState().copy(isBlocked = AsyncData.Success(true)),
|
||||
aRoomMemberDetailsState().copy(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block),
|
||||
aRoomMemberDetailsState().copy(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock),
|
||||
aRoomMemberDetailsState().copy(isBlocked = AsyncData.Loading(true)),
|
||||
aRoomMemberDetailsState().copy(startDmActionState = AsyncAction.Loading),
|
||||
aRoomMemberDetailsState(userName = null),
|
||||
aRoomMemberDetailsState(isBlocked = AsyncData.Success(true)),
|
||||
aRoomMemberDetailsState(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block),
|
||||
aRoomMemberDetailsState(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock),
|
||||
aRoomMemberDetailsState(isBlocked = AsyncData.Loading(true)),
|
||||
aRoomMemberDetailsState(startDmActionState = AsyncAction.Loading),
|
||||
// Add other states here
|
||||
)
|
||||
}
|
||||
|
||||
fun aRoomMemberDetailsState() = RoomMemberDetailsState(
|
||||
userId = "@daniel:domain.com",
|
||||
userName = "Daniel",
|
||||
avatarUrl = null,
|
||||
isBlocked = AsyncData.Success(false),
|
||||
startDmActionState = AsyncAction.Uninitialized,
|
||||
displayConfirmationDialog = null,
|
||||
isCurrentUser = false,
|
||||
eventSink = {},
|
||||
fun aRoomMemberDetailsState(
|
||||
userId: String = "@daniel:domain.com",
|
||||
userName: String? = "Daniel",
|
||||
avatarUrl: String? = null,
|
||||
isBlocked: AsyncData<Boolean> = AsyncData.Success(false),
|
||||
startDmActionState: AsyncAction<RoomId> = AsyncAction.Uninitialized,
|
||||
displayConfirmationDialog: RoomMemberDetailsState.ConfirmationDialog? = null,
|
||||
isCurrentUser: Boolean = false,
|
||||
eventSink: (RoomMemberDetailsEvents) -> Unit = {},
|
||||
) = RoomMemberDetailsState(
|
||||
userId = userId,
|
||||
userName = userName,
|
||||
avatarUrl = avatarUrl,
|
||||
isBlocked = isBlocked,
|
||||
startDmActionState = startDmActionState,
|
||||
displayConfirmationDialog = displayConfirmationDialog,
|
||||
isCurrentUser = isCurrentUser,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
sealed interface RoomListEvents {
|
||||
data class UpdateFilter(val newFilter: String) : RoomListEvents
|
||||
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
|
||||
data object DismissRequestVerificationPrompt : RoomListEvents
|
||||
data object DismissRecoveryKeyPrompt : RoomListEvents
|
||||
|
||||
@@ -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.filters.RoomListFiltersPresenter
|
||||
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.Presenter
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
@@ -74,13 +76,15 @@ class RoomListPresenter @Inject constructor(
|
||||
private val inviteStateDataSource: InviteStateDataSource,
|
||||
private val leaveRoomPresenter: LeaveRoomPresenter,
|
||||
private val roomListDataSource: RoomListDataSource,
|
||||
private val encryptionService: EncryptionService,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val indicatorService: IndicatorService,
|
||||
private val filtersPresenter: RoomListFiltersPresenter,
|
||||
private val searchPresenter: Presenter<RoomListSearchState>,
|
||||
private val migrationScreenPresenter: MigrationScreenPresenter,
|
||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||
) : Presenter<RoomListState> {
|
||||
private val encryptionService: EncryptionService = client.encryptionService()
|
||||
|
||||
@Composable
|
||||
override fun present(): RoomListState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -91,10 +95,10 @@ class RoomListPresenter @Inject constructor(
|
||||
val roomList by produceState(initialValue = AsyncData.Loading()) {
|
||||
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 filtersState = filtersPresenter.present()
|
||||
val searchState = searchPresenter.present()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
roomListDataSource.launchIn(this)
|
||||
@@ -125,21 +129,14 @@ class RoomListPresenter @Inject constructor(
|
||||
// Avatar indicator
|
||||
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
|
||||
|
||||
var displaySearchResults by rememberSaveable { mutableStateOf(false) }
|
||||
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
|
||||
|
||||
fun handleEvents(event: RoomListEvents) {
|
||||
when (event) {
|
||||
is RoomListEvents.UpdateFilter -> roomListDataSource.updateFilter(event.newFilter)
|
||||
is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range)
|
||||
RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true
|
||||
RoomListEvents.DismissRecoveryKeyPrompt -> recoveryKeyPromptDismissed = true
|
||||
RoomListEvents.ToggleSearchResults -> {
|
||||
if (displaySearchResults) {
|
||||
roomListDataSource.updateFilter("")
|
||||
}
|
||||
displaySearchResults = !displaySearchResults
|
||||
}
|
||||
RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
|
||||
is RoomListEvents.ShowContextMenu -> {
|
||||
coroutineScope.showContextMenu(event, contextMenu)
|
||||
}
|
||||
@@ -147,7 +144,6 @@ class RoomListPresenter @Inject constructor(
|
||||
contextMenu.value = RoomListState.ContextMenu.Hidden
|
||||
}
|
||||
is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(event.roomId))
|
||||
|
||||
is RoomListEvents.SetRoomIsFavorite -> coroutineScope.launch {
|
||||
client.getRoom(event.roomId)?.use { room ->
|
||||
room.setIsFavorite(event.isFavorite)
|
||||
@@ -178,17 +174,15 @@ class RoomListPresenter @Inject constructor(
|
||||
matrixUser = matrixUser.value,
|
||||
showAvatarIndicator = showAvatarIndicator,
|
||||
roomList = roomList,
|
||||
filter = filter,
|
||||
filteredRoomList = filteredRoomList,
|
||||
displayVerificationPrompt = displayVerificationPrompt,
|
||||
displayRecoveryKeyPrompt = displayRecoveryKeyPrompt,
|
||||
snackbarMessage = snackbarMessage,
|
||||
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
||||
invitesState = inviteStateDataSource.inviteState(),
|
||||
displaySearchResults = displaySearchResults,
|
||||
contextMenu = contextMenu.value,
|
||||
leaveRoomState = leaveRoomState,
|
||||
filtersState = filtersState,
|
||||
searchState = searchState,
|
||||
displayMigrationStatus = isMigrating,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
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.search.RoomListSearchState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -31,17 +32,15 @@ data class RoomListState(
|
||||
val matrixUser: MatrixUser?,
|
||||
val showAvatarIndicator: Boolean,
|
||||
val roomList: AsyncData<ImmutableList<RoomListRoomSummary>>,
|
||||
val filter: String?,
|
||||
val filteredRoomList: ImmutableList<RoomListRoomSummary>,
|
||||
val displayVerificationPrompt: Boolean,
|
||||
val displayRecoveryKeyPrompt: Boolean,
|
||||
val hasNetworkConnection: Boolean,
|
||||
val snackbarMessage: SnackbarMessage?,
|
||||
val invitesState: InvitesState,
|
||||
val displaySearchResults: Boolean,
|
||||
val contextMenu: ContextMenu,
|
||||
val leaveRoomState: LeaveRoomState,
|
||||
val filtersState: RoomListFiltersState,
|
||||
val searchState: RoomListSearchState,
|
||||
val displayMigrationStatus: Boolean,
|
||||
val eventSink: (RoomListEvents) -> Unit,
|
||||
) {
|
||||
|
||||
@@ -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.model.RoomListRoomSummary
|
||||
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.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
@@ -42,14 +43,13 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
|
||||
aRoomListState().copy(hasNetworkConnection = false),
|
||||
aRoomListState().copy(invitesState = InvitesState.SeenInvites),
|
||||
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(isFavorite = true)),
|
||||
aRoomListState().copy(displayRecoveryKeyPrompt = true),
|
||||
aRoomListState().copy(roomList = AsyncData.Success(persistentListOf())),
|
||||
aRoomListState().copy(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())),
|
||||
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"),
|
||||
showAvatarIndicator = false,
|
||||
roomList = AsyncData.Success(aRoomListRoomSummaryList()),
|
||||
filter = "filter",
|
||||
filteredRoomList = aRoomListRoomSummaryList(),
|
||||
hasNetworkConnection = true,
|
||||
snackbarMessage = null,
|
||||
displayVerificationPrompt = false,
|
||||
displayRecoveryKeyPrompt = false,
|
||||
invitesState = InvitesState.NoInvites,
|
||||
displaySearchResults = false,
|
||||
contextMenu = RoomListState.ContextMenu.Hidden,
|
||||
leaveRoomState = aLeaveRoomState(),
|
||||
filtersState = aRoomListFiltersState(),
|
||||
searchState = aRoomListSearchState(),
|
||||
displayMigrationStatus = false,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
@@ -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.migration.MigrationScreenView
|
||||
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.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
@@ -119,8 +119,8 @@ fun RoomListView(
|
||||
onMenuActionClicked = onMenuActionClicked,
|
||||
)
|
||||
// This overlaid view will only be visible when state.displaySearchResults is true
|
||||
RoomListSearchResultView(
|
||||
state = state,
|
||||
RoomListSearchView(
|
||||
state = state.searchState,
|
||||
onRoomClicked = onRoomClicked,
|
||||
onRoomLongClicked = { onRoomLongClicked(it) },
|
||||
modifier = Modifier
|
||||
@@ -209,8 +209,7 @@ private fun RoomListContent(
|
||||
RoomListTopBar(
|
||||
matrixUser = state.matrixUser,
|
||||
showAvatarIndicator = state.showAvatarIndicator,
|
||||
areSearchResultsDisplayed = state.displaySearchResults,
|
||||
onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
|
||||
areSearchResultsDisplayed = state.searchState.isSearchActive,
|
||||
onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) },
|
||||
onMenuActionClicked = onMenuActionClicked,
|
||||
onOpenSettings = onOpenSettings,
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
package io.element.android.features.roomlist.impl.components
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -87,7 +86,6 @@ fun RoomListTopBar(
|
||||
matrixUser: MatrixUser?,
|
||||
showAvatarIndicator: Boolean,
|
||||
areSearchResultsDisplayed: Boolean,
|
||||
onFilterChanged: (String) -> Unit,
|
||||
onToggleSearch: () -> Unit,
|
||||
onMenuActionClicked: (RoomListMenuAction) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
@@ -95,15 +93,6 @@ fun RoomListTopBar(
|
||||
displayMenuItems: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun closeFilter() {
|
||||
onFilterChanged("")
|
||||
}
|
||||
|
||||
BackHandler(enabled = areSearchResultsDisplayed) {
|
||||
closeFilter()
|
||||
onToggleSearch()
|
||||
}
|
||||
|
||||
DefaultRoomListTopBar(
|
||||
matrixUser = matrixUser,
|
||||
showAvatarIndicator = showAvatarIndicator,
|
||||
|
||||
@@ -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.RoomSummary
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@@ -54,9 +50,7 @@ class RoomListDataSource @Inject constructor(
|
||||
observeNotificationSettings()
|
||||
}
|
||||
|
||||
private val _filter = MutableStateFlow("")
|
||||
private val _allRooms = MutableSharedFlow<ImmutableList<RoomListRoomSummary>>(replay = 1)
|
||||
private val _filteredRooms = MutableStateFlow<ImmutableList<RoomListRoomSummary>>(persistentListOf())
|
||||
|
||||
private val lock = Mutex()
|
||||
private val diffCache = MutableListDiffCache<RoomListRoomSummary>()
|
||||
@@ -72,29 +66,9 @@ class RoomListDataSource @Inject constructor(
|
||||
replaceWith(roomSummaries)
|
||||
}
|
||||
.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 filteredRooms: StateFlow<ImmutableList<RoomListRoomSummary>> = _filteredRooms
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
private fun observeNotificationSettings() {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package io.element.android.features.roomlist.impl.search
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
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.draw.drawBehind
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.contentType
|
||||
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
|
||||
|
||||
@Composable
|
||||
internal fun RoomListSearchResultView(
|
||||
state: RoomListState,
|
||||
internal fun RoomListSearchView(
|
||||
state: RoomListSearchState,
|
||||
onRoomClicked: (RoomId) -> Unit,
|
||||
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BackHandler(enabled = state.isSearchActive) {
|
||||
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = state.displaySearchResults,
|
||||
visible = state.isSearchActive,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.applyIf(state.displaySearchResults, ifTrue = {
|
||||
.applyIf(state.isSearchActive, ifTrue = {
|
||||
// Disable input interaction to underlying views
|
||||
pointerInput(Unit) {}
|
||||
})
|
||||
) {
|
||||
if (state.displaySearchResults) {
|
||||
RoomListSearchResultContent(
|
||||
if (state.isSearchActive) {
|
||||
RoomListSearchContent(
|
||||
state = state,
|
||||
onRoomClicked = onRoomClicked,
|
||||
onRoomLongClicked = onRoomLongClicked,
|
||||
@@ -99,15 +95,15 @@ internal fun RoomListSearchResultView(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RoomListSearchResultContent(
|
||||
state: RoomListState,
|
||||
private fun RoomListSearchContent(
|
||||
state: RoomListSearchState,
|
||||
onRoomClicked: (RoomId) -> Unit,
|
||||
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
|
||||
) {
|
||||
val borderColor = MaterialTheme.colorScheme.tertiary
|
||||
val strokeWidth = 1.dp
|
||||
fun onBackButtonPressed() {
|
||||
state.eventSink(RoomListEvents.ToggleSearchResults)
|
||||
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
|
||||
}
|
||||
|
||||
fun onRoomClicked(room: RoomListRoomSummary) {
|
||||
@@ -126,7 +122,7 @@ private fun RoomListSearchResultContent(
|
||||
},
|
||||
navigationIcon = { BackButton(onClick = ::onBackButtonPressed) },
|
||||
title = {
|
||||
val filter = state.filter.orEmpty()
|
||||
val filter = state.query
|
||||
val focusRequester = FocusRequester()
|
||||
TextField(
|
||||
modifier = Modifier
|
||||
@@ -134,7 +130,7 @@ private fun RoomListSearchResultContent(
|
||||
.focusRequester(focusRequester),
|
||||
value = filter,
|
||||
singleLine = true,
|
||||
onValueChange = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
|
||||
onValueChange = { state.eventSink(RoomListSearchEvents.QueryChanged(it)) },
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
@@ -147,7 +143,7 @@ private fun RoomListSearchResultContent(
|
||||
trailingIcon = {
|
||||
if (filter.isNotEmpty()) {
|
||||
IconButton(onClick = {
|
||||
state.eventSink(RoomListEvents.UpdateFilter(""))
|
||||
state.eventSink(RoomListSearchEvents.ClearQuery)
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
@@ -158,8 +154,8 @@ private fun RoomListSearchResultContent(
|
||||
}
|
||||
)
|
||||
|
||||
LaunchedEffect(state.displaySearchResults) {
|
||||
if (state.displaySearchResults) {
|
||||
LaunchedEffect(state.isSearchActive) {
|
||||
if (state.isSearchActive) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
@@ -168,39 +164,16 @@ private fun RoomListSearchResultContent(
|
||||
)
|
||||
}
|
||||
) { 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(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.nestedScroll(nestedScrollConnection),
|
||||
state = lazyListState,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
items(
|
||||
items = state.filteredRoomList,
|
||||
items = state.results,
|
||||
contentType = { room -> room.contentType() },
|
||||
) { room ->
|
||||
RoomSummaryRow(
|
||||
@@ -216,9 +189,9 @@ private fun RoomListSearchResultContent(
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RoomListSearchResultContentPreview() = ElementPreview {
|
||||
RoomListSearchResultContent(
|
||||
state = aRoomListState(),
|
||||
internal fun RoomListSearchResultContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview {
|
||||
RoomListSearchContent(
|
||||
state = state,
|
||||
onRoomClicked = {},
|
||||
onRoomLongClicked = {}
|
||||
)
|
||||
@@ -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.MigrationScreenPresenter
|
||||
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.test.A_FORMATTED_DATE
|
||||
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
|
||||
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.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.libraries.indicator.impl.DefaultIndicatorService
|
||||
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.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.roomlist.RoomListService
|
||||
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_EXCEPTION
|
||||
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_USER_ID
|
||||
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.roomlist.FakeRoomListService
|
||||
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.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
@@ -78,6 +83,7 @@ import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class RoomListPresenterTests {
|
||||
@get:Rule
|
||||
@@ -106,9 +112,12 @@ class RoomListPresenterTests {
|
||||
fun `present - show avatar indicator`() = runTest {
|
||||
val scope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val encryptionService = FakeEncryptionService()
|
||||
val matrixClient = FakeMatrixClient(
|
||||
encryptionService = encryptionService,
|
||||
)
|
||||
val sessionVerificationService = FakeSessionVerificationService()
|
||||
val presenter = createRoomListPresenter(
|
||||
encryptionService = encryptionService,
|
||||
client = matrixClient,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
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
|
||||
fun `present - load 1 room with success`() = runTest {
|
||||
val roomListService = FakeRoomListService()
|
||||
@@ -175,7 +166,7 @@ class RoomListPresenterTests {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.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
|
||||
val initialItems = initialState.roomList.dataOrNull().orEmpty()
|
||||
assertThat(initialItems.size).isEqualTo(16)
|
||||
@@ -197,51 +188,7 @@ class RoomListPresenterTests {
|
||||
numberOfUnreadMessages = 2,
|
||||
)
|
||||
)
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@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()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
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
|
||||
fun `present - sets invite state`() = runTest {
|
||||
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
|
||||
fun `present - change in notification settings updates the summary for decorations`() = runTest {
|
||||
val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
|
||||
@@ -516,7 +524,6 @@ class RoomListPresenterTests {
|
||||
|
||||
// The migration screen is not shown anymore
|
||||
assertThat(awaitItem().displayMigrationStatus).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
@@ -567,14 +574,15 @@ class RoomListPresenterTests {
|
||||
givenFormat(A_FORMATTED_DATE)
|
||||
},
|
||||
roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(),
|
||||
encryptionService: EncryptionService = FakeEncryptionService(),
|
||||
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)),
|
||||
coroutineScope: CoroutineScope,
|
||||
migrationScreenPresenter: MigrationScreenPresenter = MigrationScreenPresenter(
|
||||
matrixClient = client,
|
||||
migrationScreenStore = InMemoryMigrationScreenStore(),
|
||||
),
|
||||
filtersPresenter: RoomListFiltersPresenter = RoomListFiltersPresenter(),
|
||||
searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() },
|
||||
) = RoomListPresenter(
|
||||
client = client,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
@@ -592,14 +600,14 @@ class RoomListPresenterTests {
|
||||
notificationSettingsService = client.notificationSettingsService(),
|
||||
appScope = coroutineScope
|
||||
),
|
||||
encryptionService = encryptionService,
|
||||
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)),
|
||||
featureFlagService = featureFlagService,
|
||||
indicatorService = DefaultIndicatorService(
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
encryptionService = encryptionService,
|
||||
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)),
|
||||
encryptionService = client.encryptionService(),
|
||||
featureFlagService = featureFlagService,
|
||||
),
|
||||
migrationScreenPresenter = migrationScreenPresenter,
|
||||
searchPresenter = searchPresenter,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
filtersPresenter = filtersPresenter,
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ compose_bom = "2024.02.00"
|
||||
composecompiler = "1.5.9"
|
||||
|
||||
# Coroutines
|
||||
coroutines = "1.7.3"
|
||||
coroutines = "1.8.0"
|
||||
|
||||
# Accompanist
|
||||
accompanist = "0.34.0"
|
||||
@@ -34,7 +34,7 @@ test_core = "1.5.0"
|
||||
coil = "2.5.0"
|
||||
datetime = "0.5.0"
|
||||
dependencyAnalysis = "1.30.0"
|
||||
serialization_json = "1.6.2"
|
||||
serialization_json = "1.6.3"
|
||||
showkase = "1.0.2"
|
||||
appyx = "1.4.0"
|
||||
sqldelight = "2.0.1"
|
||||
@@ -51,7 +51,7 @@ autoservice = "1.1.1"
|
||||
junit = "4.13.2"
|
||||
androidx-test-ext-junit = "1.1.5"
|
||||
espresso-core = "3.5.1"
|
||||
kover = "0.7.5"
|
||||
kover = "0.7.6"
|
||||
|
||||
[libraries]
|
||||
# Project
|
||||
@@ -132,7 +132,7 @@ test_junitext = "androidx.test.ext:junit:1.1.5"
|
||||
test_mockk = "io.mockk:mockk:1.13.9"
|
||||
test_konsist = "com.lemonappdev:konsist:0.13.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_robolectric = "org.robolectric:robolectric:4.11.1"
|
||||
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"
|
||||
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
|
||||
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"
|
||||
maplibre = "org.maplibre.gl:android-sdk:10.2.0"
|
||||
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"
|
||||
|
||||
# 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"
|
||||
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:aa14cbcdf81af2746d20a71779ec751f971e1d7f"
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
|
||||
@Composable
|
||||
fun FloatingActionButton(
|
||||
@@ -48,7 +50,7 @@ fun FloatingActionButton(
|
||||
) {
|
||||
androidx.compose.material3.FloatingActionButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
modifier = modifier.testTag(TestTags.floatingActionButton),
|
||||
shape = shape,
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
|
||||
@@ -27,7 +27,17 @@ data class RoomMember(
|
||||
val powerLevel: Long,
|
||||
val normalizedPowerLevel: Long,
|
||||
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.
|
||||
* If the display name is null, the user ID is returned.
|
||||
@@ -49,6 +59,10 @@ enum class RoomMembershipState {
|
||||
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 {
|
||||
return displayName?.takeIf { it.isNotEmpty() } ?: userId.value
|
||||
}
|
||||
|
||||
@@ -28,11 +28,26 @@ import kotlin.time.Duration
|
||||
* Can be retrieved from [RoomListService] methods.
|
||||
*/
|
||||
interface RoomList {
|
||||
/**
|
||||
* The loading state of the room list.
|
||||
*/
|
||||
sealed interface LoadingState {
|
||||
data object NotLoaded : 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.
|
||||
*/
|
||||
|
||||
@@ -18,38 +18,68 @@ package io.element.android.libraries.matrix.api.roomlist
|
||||
|
||||
sealed interface RoomListFilter {
|
||||
companion object {
|
||||
/**
|
||||
* Create a filter that matches all the given filters.
|
||||
*/
|
||||
fun all(vararg filters: RoomListFilter): RoomListFilter {
|
||||
return All(filters.toList())
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filter that matches any of the given filters.
|
||||
*/
|
||||
fun any(vararg filters: RoomListFilter): RoomListFilter {
|
||||
return Any(filters.toList())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A filter that matches all the given filters.
|
||||
*/
|
||||
data class All(
|
||||
val filters: List<RoomListFilter>
|
||||
) : RoomListFilter
|
||||
|
||||
/**
|
||||
* A filter that matches any of the given filters.
|
||||
*/
|
||||
data class Any(
|
||||
val filters: List<RoomListFilter>
|
||||
) : RoomListFilter
|
||||
|
||||
/**
|
||||
* A filter that matches rooms that are not left.
|
||||
*/
|
||||
data object NonLeft : RoomListFilter
|
||||
|
||||
/**
|
||||
* A filter that matches rooms that are unread.
|
||||
*/
|
||||
data object Unread : RoomListFilter
|
||||
|
||||
/**
|
||||
* A filter that matches either Group or People rooms.
|
||||
*/
|
||||
sealed interface Category : RoomListFilter {
|
||||
data object Group : Category
|
||||
data object People : Category
|
||||
}
|
||||
|
||||
/**
|
||||
* A filter that matches no room.
|
||||
*/
|
||||
data object None : RoomListFilter
|
||||
|
||||
/**
|
||||
* A filter that matches rooms with a name using a normalized match.
|
||||
*/
|
||||
data class NormalizedMatchRoomName(
|
||||
val pattern: String
|
||||
) : RoomListFilter
|
||||
|
||||
/**
|
||||
* A filter that matches rooms with a name using a fuzzy match.
|
||||
*/
|
||||
data class FuzzyMatchRoomName(
|
||||
val pattern: String
|
||||
) : RoomListFilter
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package io.element.android.libraries.matrix.api.roomlist
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
@@ -39,6 +40,20 @@ interface RoomListService {
|
||||
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.
|
||||
* This will exclude some rooms like the invites, or spaces.
|
||||
|
||||
@@ -24,5 +24,5 @@ import kotlinx.parcelize.Parcelize
|
||||
data class MatrixUser(
|
||||
val userId: UserId,
|
||||
val displayName: String? = null,
|
||||
val avatarUrl: String? = null
|
||||
val avatarUrl: String? = null,
|
||||
) : Parcelable
|
||||
|
||||
@@ -197,10 +197,10 @@ class RustMatrixClient(
|
||||
RustRoomListService(
|
||||
innerRoomListService = innerRoomListService,
|
||||
sessionCoroutineScope = sessionCoroutineScope,
|
||||
sessionDispatcher = sessionDispatcher,
|
||||
roomListFactory = RoomListFactory(
|
||||
innerRoomListService = innerRoomListService,
|
||||
coroutineScope = sessionCoroutineScope,
|
||||
dispatcher = sessionDispatcher,
|
||||
sessionCoroutineScope = sessionCoroutineScope,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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.room.RoomMember
|
||||
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.RoomMember as RustRoomMember
|
||||
|
||||
@@ -33,9 +34,17 @@ object RoomMemberMapper {
|
||||
it.powerLevel(),
|
||||
it.normalizedPowerLevel(),
|
||||
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 =
|
||||
when (membershipState) {
|
||||
RustMembershipState.BAN -> RoomMembershipState.BAN
|
||||
|
||||
@@ -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.RoomListFilter
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -30,13 +29,14 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
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.RoomListService as InnerRoomListService
|
||||
|
||||
internal class RoomListFactory(
|
||||
private val innerRoomListService: InnerRoomListService,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
private val innerRoomListService: RoomListService,
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
|
||||
) {
|
||||
/**
|
||||
@@ -44,18 +44,21 @@ internal class RoomListFactory(
|
||||
*/
|
||||
fun createRoomList(
|
||||
pageSize: Int,
|
||||
coroutineScope: CoroutineScope = sessionCoroutineScope,
|
||||
coroutineContext: CoroutineContext = EmptyCoroutineContext,
|
||||
initialFilter: RoomListFilter = RoomListFilter.all(),
|
||||
innerProvider: suspend () -> InnerRoomList
|
||||
): DynamicRoomList {
|
||||
val loadingStateFlow: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded)
|
||||
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
|
||||
val dynamicEvents = MutableSharedFlow<RoomListDynamicEvents>(replay = 100)
|
||||
val currentFilter = MutableStateFlow(initialFilter)
|
||||
val loadedPages = MutableStateFlow(1)
|
||||
var innerRoomList: InnerRoomList? = null
|
||||
coroutineScope.launch(dispatcher) {
|
||||
|
||||
coroutineScope.launch(coroutineContext) {
|
||||
innerRoomList = innerProvider()
|
||||
innerRoomList?.let { innerRoomList ->
|
||||
innerRoomList.entriesFlow(
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package io.element.android.libraries.matrix.impl.roomlist
|
||||
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -28,11 +27,12 @@ import org.matrix.rustcomponents.sdk.RoomListServiceInterface
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
class RoomSummaryListProcessor(
|
||||
private val roomSummaries: MutableStateFlow<List<RoomSummary>>,
|
||||
private val roomListService: RoomListServiceInterface,
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
private val coroutineContext: CoroutineContext,
|
||||
private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
|
||||
) {
|
||||
private val roomSummariesByIdentifier = HashMap<String, RoomSummary>()
|
||||
@@ -130,7 +130,7 @@ class RoomSummaryListProcessor(
|
||||
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 {
|
||||
val mutableRoomSummaries = roomSummaries.value.toMutableList()
|
||||
block(mutableRoomSummaries)
|
||||
|
||||
@@ -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.RoomListService
|
||||
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -42,10 +43,31 @@ private const val DEFAULT_PAGE_SIZE = 20
|
||||
internal class RustRoomListService(
|
||||
private val innerRoomListService: InnerRustRoomListService,
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
roomListFactory: RoomListFactory,
|
||||
private val sessionDispatcher: CoroutineDispatcher,
|
||||
private val roomListFactory: RoomListFactory,
|
||||
) : 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(
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
coroutineContext = sessionDispatcher,
|
||||
initialFilter = RoomListFilter.all(RoomListFilter.NonLeft),
|
||||
) {
|
||||
innerRoomListService.allRooms()
|
||||
@@ -53,6 +75,7 @@ internal class RustRoomListService(
|
||||
|
||||
override val invites: RoomList = roomListFactory.createRoomList(
|
||||
pageSize = Int.MAX_VALUE,
|
||||
coroutineContext = sessionDispatcher,
|
||||
) {
|
||||
innerRoomListService.invites()
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import org.matrix.rustcomponents.sdk.NoPointer
|
||||
import org.matrix.rustcomponents.sdk.Room
|
||||
import org.matrix.rustcomponents.sdk.RoomMember
|
||||
import org.matrix.rustcomponents.sdk.RoomMembersIterator
|
||||
import uniffi.matrix_sdk.RoomMemberRole
|
||||
|
||||
class RoomMemberListFetcherTest {
|
||||
@Test
|
||||
@@ -268,6 +269,7 @@ class FakeRustRoomMember(
|
||||
private val membership: MembershipState = MembershipState.JOIN,
|
||||
private val isNameAmbiguous: Boolean = false,
|
||||
private val powerLevel: Long = 0L,
|
||||
private val role: RoomMemberRole = RoomMemberRole.USER,
|
||||
) : RoomMember(NoPointer) {
|
||||
override fun userId(): String {
|
||||
return userId.value
|
||||
@@ -300,4 +302,8 @@ class FakeRustRoomMember(
|
||||
override fun isIgnored(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun suggestedRoleForPowerLevel(): RoomMemberRole {
|
||||
return role
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ class RoomSummaryListProcessorTests {
|
||||
private fun TestScope.createProcessor() = RoomSummaryListProcessor(
|
||||
summaries,
|
||||
fakeRoomListService,
|
||||
dispatcher = StandardTestDispatcher(testScheduler),
|
||||
coroutineContext = StandardTestDispatcher(testScheduler),
|
||||
roomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
|
||||
)
|
||||
|
||||
|
||||
@@ -103,6 +103,10 @@ class FakeEncryptionService : EncryptionService {
|
||||
backupStateStateFlow.emit(state)
|
||||
}
|
||||
|
||||
suspend fun emitRecoveryState(state: RecoveryState) {
|
||||
recoveryStateStateFlow.emit(state)
|
||||
}
|
||||
|
||||
suspend fun emitEnableRecoveryProgress(state: EnableRecoveryProgress) {
|
||||
enableRecoveryProgressStateFlow.emit(state)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ fun aRoomMember(
|
||||
powerLevel: Long = 0L,
|
||||
normalizedPowerLevel: Long = 0L,
|
||||
isIgnored: Boolean = false,
|
||||
role: RoomMember.Role = RoomMember.Role.USER,
|
||||
) = RoomMember(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
@@ -38,4 +39,5 @@ fun aRoomMember(
|
||||
powerLevel = powerLevel,
|
||||
normalizedPowerLevel = normalizedPowerLevel,
|
||||
isIgnored = isIgnored,
|
||||
role = role,
|
||||
)
|
||||
|
||||
@@ -53,6 +53,10 @@ fun aRoomSummaryFilled(
|
||||
)
|
||||
)
|
||||
|
||||
fun aRoomSummaryFilled(
|
||||
details: RoomSummaryDetails = aRoomSummaryDetails(),
|
||||
) = RoomSummary.Filled(details)
|
||||
|
||||
fun aRoomSummaryDetails(
|
||||
roomId: RoomId = A_ROOM_ID,
|
||||
name: String = A_ROOM_NAME,
|
||||
|
||||
@@ -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.RoomListService
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@@ -59,13 +60,20 @@ class FakeRoomListService : RoomListService {
|
||||
var latestSlidingSyncRange: IntRange? = null
|
||||
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,
|
||||
allRoomsLoadingStateFlow,
|
||||
MutableStateFlow(RoomListFilter.all())
|
||||
)
|
||||
|
||||
override val invites: RoomList = SimplePagedRoomList(
|
||||
override val invites = SimplePagedRoomList(
|
||||
inviteRoomSummariesFlow,
|
||||
inviteRoomsLoadingStateFlow,
|
||||
MutableStateFlow(RoomListFilter.all())
|
||||
|
||||
@@ -25,7 +25,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
|
||||
data class SimplePagedRoomList(
|
||||
override val summaries: StateFlow<List<RoomSummary>>,
|
||||
override val summaries: MutableStateFlow<List<RoomSummary>>,
|
||||
override val loadingState: StateFlow<RoomList.LoadingState>,
|
||||
override val currentFilter: MutableStateFlow<RoomListFilter>
|
||||
) : DynamicRoomList {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -74,4 +74,14 @@ object TestTags {
|
||||
val dialogPositive = TestTag("dialog-positive")
|
||||
val dialogNegative = TestTag("dialog-negative")
|
||||
val dialogNeutral = TestTag("dialog-neutral")
|
||||
|
||||
/**
|
||||
* Floating Action Button.
|
||||
*/
|
||||
val floatingActionButton = TestTag("floating-action-button")
|
||||
|
||||
/**
|
||||
* Timeline item.
|
||||
*/
|
||||
val timelineItemSenderInfo = TestTag("timeline_item-sender_info")
|
||||
}
|
||||
|
||||
@@ -85,6 +85,8 @@ fun Project.setupKover() {
|
||||
"*Presenter\$present\$*",
|
||||
// Forked from compose
|
||||
"io.element.android.libraries.designsystem.theme.components.bottomsheet.*",
|
||||
// Test presenter
|
||||
"io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter",
|
||||
)
|
||||
annotatedBy(
|
||||
"androidx.compose.ui.tooling.preview.Preview",
|
||||
|
||||
@@ -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.migration.MigrationScreenPresenter
|
||||
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.dateformatter.impl.DateFormatters
|
||||
import io.element.android.libraries.dateformatter.impl.DefaultLastMessageTimestampFormatter
|
||||
@@ -71,6 +72,21 @@ class RoomListScreen(
|
||||
private val featureFlagService = DefaultFeatureFlagService(
|
||||
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(
|
||||
client = matrixClient,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
@@ -80,26 +96,11 @@ class RoomListScreen(
|
||||
leaveRoomPresenter = LeaveRoomPresenterImpl(matrixClient, RoomMembershipObserver(), coroutineDispatchers),
|
||||
roomListDataSource = RoomListDataSource(
|
||||
roomListService = matrixClient.roomListService,
|
||||
roomListRoomSummaryFactory = RoomListRoomSummaryFactory(
|
||||
lastMessageTimestampFormatter = DefaultLastMessageTimestampFormatter(
|
||||
localDateTimeProvider = dateTimeProvider,
|
||||
dateFormatters = dateFormatters
|
||||
),
|
||||
roomLastMessageFormatter = DefaultRoomLastMessageFormatter(
|
||||
sp = stringProvider,
|
||||
roomMembershipContentFormatter = RoomMembershipContentFormatter(
|
||||
matrixClient = matrixClient,
|
||||
sp = stringProvider
|
||||
),
|
||||
profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider),
|
||||
stateContentFormatter = StateContentFormatter(stringProvider),
|
||||
),
|
||||
),
|
||||
roomListRoomSummaryFactory = roomListRoomSummaryFactory,
|
||||
coroutineDispatchers = coroutineDispatchers,
|
||||
notificationSettingsService = matrixClient.notificationSettingsService(),
|
||||
appScope = Singleton.appScope
|
||||
),
|
||||
encryptionService = encryptionService,
|
||||
indicatorService = DefaultIndicatorService(
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
encryptionService = encryptionService,
|
||||
@@ -110,6 +111,11 @@ class RoomListScreen(
|
||||
matrixClient = matrixClient,
|
||||
migrationScreenStore = SharedPrefsMigrationScreenStore(context.getSharedPreferences("migration", Context.MODE_PRIVATE))
|
||||
),
|
||||
searchPresenter = RoomListSearchPresenter(
|
||||
roomListService = matrixClient.roomListService,
|
||||
roomSummaryFactory = roomListRoomSummaryFactory,
|
||||
coroutineDispatchers = coroutineDispatchers,
|
||||
),
|
||||
sessionPreferencesStore = DefaultSessionPreferencesStore(
|
||||
context = context,
|
||||
sessionId = matrixClient.sessionId,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user