Add user verification and verification state violation badges (#4392)
* Move `observeRoomMemberIdentityStateChange` and associated classes to `libs:matrixui` module so they can be reused * Add `EncryptionService.getUserIdentity` method to retrieve not only if the user is verified or not, but in which state they are * Fix `IdentityChangePresenter` after the previous changes * Fix `withFakeLifecycleOwner` and add `testWithLifecycleOwner` helper * Display verified badge in DM top app bar when possible * Display a verification violation warning icon next to the 'People' item in room details screen * Display either a verified badge or a verification violation warning icon next to the room members in the room member list screen * Display either a verified badge or a verification violation warning and withdraw verification button in the room member profile. Generic user profiles won't display verification state anymore since we can't easily track changes in it. * Add preview for room member details screen with verification violation identity state * Add verified and violation badge to the `Profile` list item in room details screen * Update screenshots --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
parent
b0e6b50c79
commit
fd50ce4daf
75 changed files with 889 additions and 364 deletions
|
|
@ -21,6 +21,7 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
|
@ -33,7 +34,6 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
|
|||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.messagecomposer.observeRoomMemberIdentityStateChange
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
|
|
@ -61,6 +61,8 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
|||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
|
||||
|
|
@ -76,10 +78,10 @@ import io.element.android.libraries.matrix.api.sync.isOnline
|
|||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.map
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -110,6 +112,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
private val timelineController: TimelineController,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val encryptionService: EncryptionService,
|
||||
) : Presenter<MessagesState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
|
@ -156,9 +159,6 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
var hasDismissedInviteDialog by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val roomMemberIdentityStateChanges by produceState(persistentListOf()) {
|
||||
observeRoomMemberIdentityStateChange(room)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
// Remove the unread flag on entering but don't send read receipts
|
||||
// as those will be handled by the timeline.
|
||||
|
|
@ -183,6 +183,22 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages)
|
||||
}
|
||||
|
||||
var dmUserVerificationState by remember { mutableStateOf<IdentityState?>(null) }
|
||||
|
||||
val membersState by room.membersStateFlow.collectAsState()
|
||||
val dmRoomMember by room.getDirectRoomMember(membersState)
|
||||
|
||||
// TODO use `RoomInfo.isEncrypted` as a key here once it's available
|
||||
LifecycleResumeEffect(dmRoomMember) {
|
||||
if (room.isEncrypted) {
|
||||
val dmRoomMemberId = dmRoomMember?.userId
|
||||
localCoroutineScope.launch {
|
||||
dmRoomMemberId?.let { dmUserVerificationState = encryptionService.getUserIdentity(it).getOrNull() }
|
||||
}
|
||||
}
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
fun handleEvents(event: MessagesEvents) {
|
||||
when (event) {
|
||||
is MessagesEvents.HandleAction -> {
|
||||
|
|
@ -215,7 +231,6 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
roomAvatar = roomAvatar,
|
||||
heroes = heroes,
|
||||
composerState = composerState,
|
||||
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges,
|
||||
userEventPermissions = userEventPermissions,
|
||||
voiceMessageComposerState = voiceMessageComposerState,
|
||||
timelineState = timelineState,
|
||||
|
|
@ -234,6 +249,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
appName = buildMeta.applicationName,
|
||||
roomCallState = roomCallState,
|
||||
pinnedMessagesBannerState = pinnedMessagesBannerState,
|
||||
dmUserVerificationState = dmUserVerificationState,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ package io.element.android.features.messages.impl
|
|||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||
import io.element.android.features.messages.impl.crypto.identity.RoomMemberIdentityStateChange
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||
|
|
@ -24,6 +23,7 @@ import io.element.android.libraries.architecture.AsyncData
|
|||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
|
|
@ -34,7 +34,6 @@ data class MessagesState(
|
|||
val heroes: ImmutableList<AvatarData>,
|
||||
val userEventPermissions: UserEventPermissions,
|
||||
val composerState: MessageComposerState,
|
||||
val roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange>,
|
||||
val voiceMessageComposerState: VoiceMessageComposerState,
|
||||
val timelineState: TimelineState,
|
||||
val timelineProtectionState: TimelineProtectionState,
|
||||
|
|
@ -52,5 +51,6 @@ data class MessagesState(
|
|||
val roomCallState: RoomCallState,
|
||||
val appName: String,
|
||||
val pinnedMessagesBannerState: PinnedMessagesBannerState,
|
||||
val dmUserVerificationState: IdentityState?,
|
||||
val eventSink: (MessagesEvents) -> Unit
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
|||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||
import io.element.android.features.messages.impl.actionlist.anActionListState
|
||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||
import io.element.android.features.messages.impl.crypto.identity.RoomMemberIdentityStateChange
|
||||
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
||||
|
|
@ -40,9 +39,9 @@ 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
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
|
|
@ -82,6 +81,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
|||
currentPinnedMessageIndex = 0,
|
||||
),
|
||||
),
|
||||
aMessagesState(roomName = AsyncData.Success("A DM with a very looong name"), dmUserVerificationState = IdentityState.Verified)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +94,6 @@ fun aMessagesState(
|
|||
isFullScreen = false,
|
||||
mode = MessageComposerMode.Normal,
|
||||
),
|
||||
roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange> = persistentListOf(),
|
||||
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
|
||||
timelineState: TimelineState = aTimelineState(
|
||||
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
||||
|
|
@ -112,6 +111,7 @@ fun aMessagesState(
|
|||
enableVoiceMessages: Boolean = true,
|
||||
roomCallState: RoomCallState = aStandByCallState(),
|
||||
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
|
||||
dmUserVerificationState: IdentityState? = null,
|
||||
eventSink: (MessagesEvents) -> Unit = {},
|
||||
) = MessagesState(
|
||||
roomId = RoomId("!id:domain"),
|
||||
|
|
@ -120,7 +120,6 @@ fun aMessagesState(
|
|||
heroes = persistentListOf(),
|
||||
userEventPermissions = userEventPermissions,
|
||||
composerState = composerState,
|
||||
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges,
|
||||
voiceMessageComposerState = voiceMessageComposerState,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
identityChangeState = identityChangeState,
|
||||
|
|
@ -138,6 +137,7 @@ fun aMessagesState(
|
|||
roomCallState = roomCallState,
|
||||
appName = "Element",
|
||||
pinnedMessagesBannerState = pinnedMessagesBannerState,
|
||||
dmUserVerificationState = dmUserVerificationState,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.heightIn
|
|||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredWidthIn
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
|
@ -50,6 +51,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListView
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
|
|
@ -88,6 +90,7 @@ import io.element.android.libraries.designsystem.components.dialogs.Confirmation
|
|||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
|
|
@ -188,6 +191,7 @@ fun MessagesView(
|
|||
roomAvatar = state.roomAvatar.dataOrNull(),
|
||||
heroes = state.heroes,
|
||||
roomCallState = state.roomCallState,
|
||||
isDmUserVerified = state.dmUserVerificationState?.let { it == IdentityState.Verified },
|
||||
onBackClick = { hidingKeyboard { onBackClick() } },
|
||||
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
|
|
@ -427,7 +431,7 @@ private fun MessagesViewComposerBottomSheetContents(
|
|||
onLinkClick = onLinkClick,
|
||||
)
|
||||
}
|
||||
val verificationViolation = state.roomMemberIdentityStateChanges.firstOrNull {
|
||||
val verificationViolation = state.identityChangeState.roomMemberIdentityStateChanges.firstOrNull {
|
||||
it.identityState == IdentityState.VerificationViolation
|
||||
}
|
||||
if (verificationViolation != null) {
|
||||
|
|
@ -454,6 +458,7 @@ private fun MessagesViewTopBar(
|
|||
roomAvatar: AvatarData?,
|
||||
heroes: ImmutableList<AvatarData>,
|
||||
roomCallState: RoomCallState,
|
||||
isDmUserVerified: Boolean?,
|
||||
onRoomDetailsClick: () -> Unit,
|
||||
onJoinCallClick: () -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
|
|
@ -463,22 +468,36 @@ private fun MessagesViewTopBar(
|
|||
BackButton(onClick = onBackClick)
|
||||
},
|
||||
title = {
|
||||
val roundedCornerShape = RoundedCornerShape(8.dp)
|
||||
val titleModifier = Modifier
|
||||
.clip(roundedCornerShape)
|
||||
.clickable { onRoomDetailsClick() }
|
||||
if (roomName != null && roomAvatar != null) {
|
||||
RoomAvatarAndNameRow(
|
||||
roomName = roomName,
|
||||
roomAvatar = roomAvatar,
|
||||
heroes = heroes,
|
||||
modifier = titleModifier
|
||||
)
|
||||
} else {
|
||||
IconTitlePlaceholdersRowMolecule(
|
||||
iconSize = AvatarSize.TimelineRoom.dp,
|
||||
modifier = titleModifier
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val roundedCornerShape = RoundedCornerShape(8.dp)
|
||||
val titleModifier = Modifier
|
||||
.clip(roundedCornerShape)
|
||||
.clickable { onRoomDetailsClick() }
|
||||
if (roomName != null && roomAvatar != null) {
|
||||
RoomAvatarAndNameRow(
|
||||
roomName = roomName,
|
||||
roomAvatar = roomAvatar,
|
||||
heroes = heroes,
|
||||
modifier = titleModifier
|
||||
)
|
||||
} else {
|
||||
IconTitlePlaceholdersRowMolecule(
|
||||
iconSize = AvatarSize.TimelineRoom.dp,
|
||||
modifier = titleModifier
|
||||
)
|
||||
}
|
||||
|
||||
if (isDmUserVerified == true) {
|
||||
Icon(
|
||||
modifier = Modifier.requiredWidthIn(min = 24.dp),
|
||||
imageVector = CompoundIcons.Verified(),
|
||||
tint = ElementTheme.colors.iconSuccessPrimary,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
|
|
|
|||
|
|
@ -7,15 +7,10 @@
|
|||
|
||||
package io.element.android.features.messages.impl.crypto.identity
|
||||
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.ui.room.RoomMemberIdentityStateChange
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class IdentityChangeState(
|
||||
val roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange>,
|
||||
val eventSink: (IdentityChangeEvent) -> Unit,
|
||||
)
|
||||
|
||||
data class RoomMemberIdentityStateChange(
|
||||
val identityRoomMember: IdentityRoomMember,
|
||||
val identityState: IdentityState,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,11 +11,11 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import io.element.android.features.messages.impl.messagecomposer.observeRoomMemberIdentityStateChange
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.ui.room.observeRoomMemberIdentityStateChange
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ 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.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.ui.room.IdentityRoomMember
|
||||
import io.element.android.libraries.matrix.ui.room.RoomMemberIdentityStateChange
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
class IdentityChangeStateProvider : PreviewParameterProvider<IdentityChangeState> {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
|||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.isAViolation
|
||||
import io.element.android.libraries.matrix.ui.room.RoomMemberIdentityStateChange
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.crypto.identity
|
||||
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
data class IdentityRoomMember(
|
||||
val userId: UserId,
|
||||
val displayNameOrDefault: String,
|
||||
val avatarData: AvatarData,
|
||||
)
|
||||
|
|
@ -7,9 +7,7 @@
|
|||
|
||||
package io.element.android.features.messages.impl
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.PinUnpinAction
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||
|
|
@ -49,6 +47,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
|
|||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
|
|
@ -66,7 +65,9 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
|
|||
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
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.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
|
|
@ -80,6 +81,7 @@ import io.element.android.libraries.textcomposer.model.TextEditorState
|
|||
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.FakeLifecycleOwner
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.consumeItemsUntilTimeout
|
||||
|
|
@ -87,8 +89,8 @@ import io.element.android.tests.testutils.lambda.assert
|
|||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import io.element.android.tests.testutils.testWithLifecycleOwner
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
|
|
@ -107,9 +109,7 @@ class MessagesPresenterTest {
|
|||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createMessagesPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = consumeItemsUntilTimeout().last()
|
||||
assertThat(initialState.roomId).isEqualTo(A_ROOM_ID)
|
||||
assertThat(initialState.roomName).isEqualTo(AsyncData.Success(""))
|
||||
|
|
@ -137,9 +137,7 @@ class MessagesPresenterTest {
|
|||
)
|
||||
assertThat(room.markAsReadCalls).isEmpty()
|
||||
val presenter = createMessagesPresenter(matrixRoom = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
runCurrent()
|
||||
assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
|
|
@ -166,9 +164,7 @@ class MessagesPresenterTest {
|
|||
canUserPinUnpinResult = { Result.success(true) },
|
||||
)
|
||||
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID.toEventOrTransactionId()))
|
||||
|
|
@ -202,9 +198,7 @@ class MessagesPresenterTest {
|
|||
canUserPinUnpinResult = { Result.success(true) },
|
||||
)
|
||||
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID.toEventOrTransactionId()))
|
||||
initialState.eventSink(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID.toEventOrTransactionId()))
|
||||
|
|
@ -225,9 +219,7 @@ class MessagesPresenterTest {
|
|||
onForwardEventClickLambda = onForwardEventClickLambda,
|
||||
)
|
||||
val presenter = createMessagesPresenter(navigator = navigator)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
|
|
@ -240,9 +232,7 @@ class MessagesPresenterTest {
|
|||
val clipboardHelper = FakeClipboardHelper()
|
||||
val event = aMessageEvent()
|
||||
val presenter = createMessagesPresenter(clipboardHelper = clipboardHelper)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.CopyText, event))
|
||||
skipItems(2)
|
||||
|
|
@ -267,9 +257,7 @@ class MessagesPresenterTest {
|
|||
clipboardHelper = clipboardHelper,
|
||||
matrixRoom = matrixRoom,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.CopyLink, event))
|
||||
skipItems(2)
|
||||
|
|
@ -283,9 +271,7 @@ class MessagesPresenterTest {
|
|||
val presenter = createMessagesPresenter(
|
||||
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent()))
|
||||
awaitItem()
|
||||
|
|
@ -303,9 +289,7 @@ class MessagesPresenterTest {
|
|||
@Test
|
||||
fun `present - handle action reply to an event with no id does nothing`() = runTest {
|
||||
val presenter = createMessagesPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null)))
|
||||
skipItems(1)
|
||||
|
|
@ -318,9 +302,7 @@ class MessagesPresenterTest {
|
|||
val presenter = createMessagesPresenter(
|
||||
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
val mediaMessage = aMessageEvent(
|
||||
content = TimelineItemImageContent(
|
||||
|
|
@ -360,9 +342,7 @@ class MessagesPresenterTest {
|
|||
val presenter = createMessagesPresenter(
|
||||
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
val mediaMessage = aMessageEvent(
|
||||
content = TimelineItemVideoContent(
|
||||
|
|
@ -403,9 +383,7 @@ class MessagesPresenterTest {
|
|||
val presenter = createMessagesPresenter(
|
||||
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
val mediaMessage = aMessageEvent(
|
||||
content = TimelineItemFileContent(
|
||||
|
|
@ -439,9 +417,7 @@ class MessagesPresenterTest {
|
|||
val presenter = createMessagesPresenter(
|
||||
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent()))
|
||||
awaitItem()
|
||||
|
|
@ -463,9 +439,7 @@ class MessagesPresenterTest {
|
|||
onEditPollClickLambda = onEditPollClickLambda
|
||||
)
|
||||
val presenter = createMessagesPresenter(navigator = navigator)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EditPoll, aMessageEvent(content = aTimelineItemPollContent())))
|
||||
awaitItem()
|
||||
|
|
@ -477,9 +451,7 @@ class MessagesPresenterTest {
|
|||
fun `present - handle action end poll`() = runTest {
|
||||
val timelineEventSink = EventsRecorder<TimelineEvents>()
|
||||
val presenter = createMessagesPresenter(timelineEventSink = timelineEventSink)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EndPoll, aMessageEvent(content = aTimelineItemPollContent())))
|
||||
delay(1)
|
||||
|
|
@ -509,9 +481,7 @@ class MessagesPresenterTest {
|
|||
matrixRoom = matrixRoom,
|
||||
coroutineDispatchers = coroutineDispatchers,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent()
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Redact, messageEvent))
|
||||
|
|
@ -529,9 +499,7 @@ class MessagesPresenterTest {
|
|||
onReportContentClickLambda = onReportContentClickLambda
|
||||
)
|
||||
val presenter = createMessagesPresenter(navigator = navigator)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent()))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
|
|
@ -542,9 +510,7 @@ class MessagesPresenterTest {
|
|||
@Test
|
||||
fun `present - handle dismiss action`() = runTest {
|
||||
val presenter = createMessagesPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.Dismiss)
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
|
|
@ -558,9 +524,7 @@ class MessagesPresenterTest {
|
|||
onShowEventDebugInfoClickLambda = onShowEventDebugInfoClickLambda
|
||||
)
|
||||
val presenter = createMessagesPresenter(navigator = navigator)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ViewSource, aMessageEvent()))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
|
|
@ -582,9 +546,7 @@ class MessagesPresenterTest {
|
|||
canUserPinUnpinResult = { Result.success(true) },
|
||||
)
|
||||
val presenter = createMessagesPresenter(matrixRoom = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
// Initially the composer doesn't have focus, so we don't show the alert
|
||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||
|
|
@ -615,9 +577,7 @@ class MessagesPresenterTest {
|
|||
canUserPinUnpinResult = { Result.success(true) },
|
||||
)
|
||||
val presenter = createMessagesPresenter(matrixRoom = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||
(initialState.composerState.textEditorState as TextEditorState.Markdown).state.hasFocus = true
|
||||
|
|
@ -640,9 +600,7 @@ class MessagesPresenterTest {
|
|||
canUserPinUnpinResult = { Result.success(true) },
|
||||
)
|
||||
val presenter = createMessagesPresenter(matrixRoom = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||
(initialState.composerState.textEditorState as TextEditorState.Markdown).state.hasFocus = true
|
||||
|
|
@ -673,9 +631,7 @@ class MessagesPresenterTest {
|
|||
)
|
||||
)
|
||||
val presenter = createMessagesPresenter(matrixRoom = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = consumeItemsUntilTimeout().last()
|
||||
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
|
||||
skipItems(1)
|
||||
|
|
@ -710,9 +666,7 @@ class MessagesPresenterTest {
|
|||
)
|
||||
)
|
||||
val presenter = createMessagesPresenter(matrixRoom = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = consumeItemsUntilTimeout().last()
|
||||
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
|
||||
skipItems(1)
|
||||
|
|
@ -739,9 +693,7 @@ class MessagesPresenterTest {
|
|||
)
|
||||
room.givenRoomMembersState(MatrixRoomMembersState.Unknown)
|
||||
val presenter = createMessagesPresenter(matrixRoom = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = consumeItemsUntilTimeout().last()
|
||||
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
|
||||
skipItems(1)
|
||||
|
|
@ -773,15 +725,15 @@ class MessagesPresenterTest {
|
|||
)
|
||||
)
|
||||
val presenter = createMessagesPresenter(matrixRoom = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = consumeItemsUntilTimeout().last()
|
||||
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
|
||||
|
||||
val loadingState = consumeItemsUntilPredicate { state ->
|
||||
state.inviteProgress.isLoading()
|
||||
}.last()
|
||||
assertThat(loadingState.inviteProgress.isLoading()).isTrue()
|
||||
|
||||
val failureState = consumeItemsUntilPredicate { state ->
|
||||
state.inviteProgress.isFailure()
|
||||
}.last()
|
||||
|
|
@ -806,9 +758,7 @@ class MessagesPresenterTest {
|
|||
canUserPinUnpinResult = { Result.success(true) },
|
||||
)
|
||||
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
skipItems(1)
|
||||
val state = awaitItem()
|
||||
assertThat(state.userEventPermissions.canSendMessage).isTrue()
|
||||
|
|
@ -832,9 +782,7 @@ class MessagesPresenterTest {
|
|||
canUserPinUnpinResult = { Result.success(true) },
|
||||
)
|
||||
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
// Default value
|
||||
assertThat(awaitItem().userEventPermissions.canSendMessage).isTrue()
|
||||
assertThat(awaitItem().userEventPermissions.canSendMessage).isFalse()
|
||||
|
|
@ -852,9 +800,7 @@ class MessagesPresenterTest {
|
|||
canUserPinUnpinResult = { Result.success(true) },
|
||||
)
|
||||
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = consumeItemsUntilPredicate { it.userEventPermissions.canRedactOwn }.last()
|
||||
assertThat(initialState.userEventPermissions.canRedactOwn).isTrue()
|
||||
assertThat(initialState.userEventPermissions.canRedactOther).isFalse()
|
||||
|
|
@ -873,9 +819,7 @@ class MessagesPresenterTest {
|
|||
canUserPinUnpinResult = { Result.success(true) },
|
||||
)
|
||||
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = consumeItemsUntilPredicate { it.userEventPermissions.canRedactOther }.last()
|
||||
assertThat(initialState.userEventPermissions.canRedactOwn).isFalse()
|
||||
assertThat(initialState.userEventPermissions.canRedactOther).isTrue()
|
||||
|
|
@ -889,9 +833,7 @@ class MessagesPresenterTest {
|
|||
val presenter = createMessagesPresenter(
|
||||
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
val poll = aMessageEvent(
|
||||
content = aTimelineItemPollContent()
|
||||
|
|
@ -925,9 +867,7 @@ class MessagesPresenterTest {
|
|||
canUserPinUnpinResult = { Result.success(true) },
|
||||
)
|
||||
val presenter = createMessagesPresenter(matrixRoom = room, analyticsService = analyticsService)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val messageEvent = aMessageEvent(
|
||||
content = aTimelineItemTextContent()
|
||||
)
|
||||
|
|
@ -965,9 +905,7 @@ class MessagesPresenterTest {
|
|||
canUserPinUnpinResult = { Result.success(true) },
|
||||
)
|
||||
val presenter = createMessagesPresenter(matrixRoom = room, analyticsService = analyticsService)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val messageEvent = aMessageEvent(
|
||||
content = aTimelineItemTextContent()
|
||||
)
|
||||
|
|
@ -1000,7 +938,7 @@ class MessagesPresenterTest {
|
|||
val presenter = createMessagesPresenter(
|
||||
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
|
||||
)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EditCaption, messageEvent))
|
||||
awaitItem()
|
||||
|
|
@ -1030,7 +968,7 @@ class MessagesPresenterTest {
|
|||
initialState = mapOf(FeatureFlags.MediaCaptionWarning.key to false)
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EditCaption, messageEvent))
|
||||
awaitItem()
|
||||
|
|
@ -1057,7 +995,7 @@ class MessagesPresenterTest {
|
|||
caption = null,
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.AddCaption, messageEvent))
|
||||
awaitItem()
|
||||
|
|
@ -1087,7 +1025,7 @@ class MessagesPresenterTest {
|
|||
caption = null,
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.AddCaption, messageEvent))
|
||||
awaitItem()
|
||||
|
|
@ -1126,7 +1064,7 @@ class MessagesPresenterTest {
|
|||
val presenter = createMessagesPresenter(
|
||||
matrixRoom = room,
|
||||
)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.RemoveCaption, messageEvent))
|
||||
|
|
@ -1140,7 +1078,7 @@ class MessagesPresenterTest {
|
|||
content = aTimelineItemTextContent()
|
||||
)
|
||||
val presenter = createMessagesPresenter()
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ViewInTimeline, messageEvent))
|
||||
|
|
@ -1148,6 +1086,39 @@ class MessagesPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when room is encrypted and a DM, the DM user's identity state is fetched onResume`() = runTest {
|
||||
val room = FakeMatrixRoom(
|
||||
sessionId = A_SESSION_ID,
|
||||
isEncrypted = true,
|
||||
isDirect = true,
|
||||
activeMemberCount = 2L,
|
||||
canUserSendMessageResult = { _, _ -> Result.success(true) },
|
||||
canRedactOwnResult = { Result.success(true) },
|
||||
canRedactOtherResult = { Result.success(true) },
|
||||
canUserJoinCallResult = { Result.success(true) },
|
||||
typingNoticeResult = { Result.success(Unit) },
|
||||
canUserPinUnpinResult = { Result.success(true) },
|
||||
).apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(persistentListOf(aRoomMember(userId = A_SESSION_ID), aRoomMember(userId = A_USER_ID_2))))
|
||||
givenRoomInfo(aRoomInfo(id = roomId, name = "", isDirect = true))
|
||||
}
|
||||
val encryptionService = FakeEncryptionService(getUserIdentityResult = { Result.success(IdentityState.Verified) })
|
||||
|
||||
val presenter = createMessagesPresenter(matrixRoom = room, encryptionService = encryptionService)
|
||||
val lifecycleOwner = FakeLifecycleOwner()
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner) {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.dmUserVerificationState).isNull()
|
||||
ensureAllEventsConsumed()
|
||||
|
||||
lifecycleOwner.givenState(Lifecycle.State.RESUMED)
|
||||
assertThat(awaitItem().dmUserVerificationState).isEqualTo(IdentityState.Verified)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createMessagesPresenter(
|
||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||
matrixRoom: MatrixRoom = FakeMatrixRoom(
|
||||
|
|
@ -1172,6 +1143,7 @@ class MessagesPresenterTest {
|
|||
textEditorState = aTextEditorStateMarkdown(initialText = "", initialFocus = false)
|
||||
)
|
||||
},
|
||||
encryptionService: FakeEncryptionService = FakeEncryptionService(),
|
||||
actionListEventSink: (ActionListEvents) -> Unit = {},
|
||||
): MessagesPresenter {
|
||||
return MessagesPresenter(
|
||||
|
|
@ -1197,6 +1169,7 @@ class MessagesPresenterTest {
|
|||
htmlConverterProvider = FakeHtmlConverterProvider(),
|
||||
timelineController = TimelineController(matrixRoom),
|
||||
permalinkParser = permalinkParser,
|
||||
encryptionService = encryptionService,
|
||||
analyticsService = analyticsService,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
|||
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.ui.room.IdentityRoomMember
|
||||
import io.element.android.libraries.matrix.ui.room.RoomMemberIdentityStateChange
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
|||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
|
|
@ -44,6 +45,7 @@ import io.element.android.libraries.matrix.ui.room.canHandleKnockRequestsAsState
|
|||
import io.element.android.libraries.matrix.ui.room.getCurrentRoomMember
|
||||
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
|
||||
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
|
||||
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
|
@ -154,6 +156,12 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
val hasMemberVerificationViolations by produceState(false) {
|
||||
room.roomMemberIdentityStateChange()
|
||||
.onEach { identities -> value = identities.any { it.identityState == IdentityState.VerificationViolation } }
|
||||
.launchIn(this)
|
||||
}
|
||||
|
||||
return RoomDetailsState(
|
||||
roomId = room.roomId,
|
||||
roomName = roomName,
|
||||
|
|
@ -180,6 +188,7 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
canShowKnockRequests = canShowKnockRequests,
|
||||
knockRequestsCount = knockRequestsCount,
|
||||
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
|
||||
hasMemberVerificationViolations = hasMemberVerificationViolations,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ data class RoomDetailsState(
|
|||
val canShowKnockRequests: Boolean,
|
||||
val knockRequestsCount: Int?,
|
||||
val canShowSecurityAndPrivacy: Boolean,
|
||||
val hasMemberVerificationViolations: Boolean,
|
||||
val eventSink: (RoomDetailsEvent) -> Unit
|
||||
) {
|
||||
val roomBadges = buildList {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import io.element.android.features.roomcall.api.RoomCallState
|
|||
import io.element.android.features.roomcall.api.aStandByCallState
|
||||
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
||||
import io.element.android.features.userprofile.api.UserProfileState
|
||||
import io.element.android.features.userprofile.api.UserProfileVerificationState
|
||||
import io.element.android.features.userprofile.shared.aUserProfileState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
|
|
@ -50,6 +51,9 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
|
|||
aRoomDetailsState(pinnedMessagesCount = 3),
|
||||
aRoomDetailsState(knockRequestsCount = null, canShowKnockRequests = true),
|
||||
aRoomDetailsState(knockRequestsCount = 4, canShowKnockRequests = true),
|
||||
aRoomDetailsState(hasMemberVerificationViolations = true),
|
||||
aDmRoomDetailsState(dmRoomMemberVerificationState = UserProfileVerificationState.VERIFIED),
|
||||
aDmRoomDetailsState(dmRoomMemberVerificationState = UserProfileVerificationState.VERIFICATION_VIOLATION),
|
||||
// Add other state here
|
||||
)
|
||||
}
|
||||
|
|
@ -110,6 +114,7 @@ fun aRoomDetailsState(
|
|||
canShowKnockRequests: Boolean = false,
|
||||
knockRequestsCount: Int? = null,
|
||||
canShowSecurityAndPrivacy: Boolean = true,
|
||||
hasMemberVerificationViolations: Boolean = false,
|
||||
eventSink: (RoomDetailsEvent) -> Unit = {},
|
||||
) = RoomDetailsState(
|
||||
roomId = roomId,
|
||||
|
|
@ -137,6 +142,7 @@ fun aRoomDetailsState(
|
|||
canShowKnockRequests = canShowKnockRequests,
|
||||
knockRequestsCount = knockRequestsCount,
|
||||
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
|
||||
hasMemberVerificationViolations = hasMemberVerificationViolations,
|
||||
eventSink = eventSink
|
||||
)
|
||||
|
||||
|
|
@ -152,6 +158,7 @@ fun aDmRoomDetailsState(
|
|||
isDmMemberIgnored: Boolean = false,
|
||||
roomName: String = "Daniel",
|
||||
isEncrypted: Boolean = true,
|
||||
dmRoomMemberVerificationState: UserProfileVerificationState = UserProfileVerificationState.UNKNOWN,
|
||||
) = aRoomDetailsState(
|
||||
roomName = roomName,
|
||||
isPublic = false,
|
||||
|
|
@ -162,5 +169,6 @@ fun aDmRoomDetailsState(
|
|||
),
|
||||
roomMemberDetailsState = aUserProfileState(
|
||||
isBlocked = AsyncData.Success(isDmMemberIgnored),
|
||||
verificationState = dmRoomMemberVerificationState,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import io.element.android.compound.theme.ElementTheme
|
|||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomView
|
||||
import io.element.android.features.roomcall.api.hasPermissionToJoin
|
||||
import io.element.android.features.userprofile.api.UserProfileVerificationState
|
||||
import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs
|
||||
import io.element.android.features.userprofile.shared.blockuser.BlockUserSection
|
||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||
|
|
@ -189,7 +190,10 @@ fun RoomDetailsView(
|
|||
}
|
||||
|
||||
state.roomMemberDetailsState?.let { dmMemberDetails ->
|
||||
ProfileItem(onClick = { onProfileClick(dmMemberDetails.userId) })
|
||||
ProfileItem(
|
||||
verificationState = dmMemberDetails.verificationState,
|
||||
onClick = { onProfileClick(dmMemberDetails.userId) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,6 +201,7 @@ fun RoomDetailsView(
|
|||
PreferenceCategory {
|
||||
MembersItem(
|
||||
memberCount = state.memberCount,
|
||||
hasVerificationViolations = state.hasMemberVerificationViolations,
|
||||
openRoomMemberList = openRoomMemberList,
|
||||
)
|
||||
if (state.canShowKnockRequests) {
|
||||
|
|
@ -555,11 +560,23 @@ private fun FavoriteItem(
|
|||
|
||||
@Composable
|
||||
private fun ProfileItem(
|
||||
verificationState: UserProfileVerificationState,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())),
|
||||
headlineContent = { Text(stringResource(id = R.string.screen_room_details_profile_row_title)) },
|
||||
trailingContent = when (verificationState) {
|
||||
UserProfileVerificationState.VERIFIED -> ListItemContent.Icon(
|
||||
iconSource = IconSource.Vector(CompoundIcons.Verified()),
|
||||
tintColor = ElementTheme.colors.iconSuccessPrimary,
|
||||
)
|
||||
UserProfileVerificationState.VERIFICATION_VIOLATION -> ListItemContent.Icon(
|
||||
iconSource = IconSource.Vector(CompoundIcons.ErrorSolid()),
|
||||
tintColor = ElementTheme.colors.iconCriticalPrimary,
|
||||
)
|
||||
else -> null
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -567,12 +584,20 @@ private fun ProfileItem(
|
|||
@Composable
|
||||
private fun MembersItem(
|
||||
memberCount: Long,
|
||||
hasVerificationViolations: Boolean,
|
||||
openRoomMemberList: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(CommonStrings.common_people)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.User())),
|
||||
trailingContent = ListItemContent.Text(memberCount.toString()),
|
||||
trailingContent = if (hasVerificationViolations) {
|
||||
ListItemContent.Icon(
|
||||
iconSource = IconSource.Vector(CompoundIcons.ErrorSolid()),
|
||||
tintColor = ElementTheme.colors.textCriticalPrimary,
|
||||
)
|
||||
} else {
|
||||
ListItemContent.Text(memberCount.toString())
|
||||
},
|
||||
onClick = openRoomMemberList,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import io.element.android.features.roomdetails.impl.members.details.RoomMemberDe
|
|||
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
|
||||
@Module
|
||||
|
|
@ -23,6 +24,7 @@ object RoomMemberModule {
|
|||
fun provideRoomMemberDetailsPresenterFactory(
|
||||
room: MatrixRoom,
|
||||
userProfilePresenterFactory: UserProfilePresenterFactory,
|
||||
encryptionService: EncryptionService,
|
||||
): RoomMemberDetailsPresenter.Factory {
|
||||
return object : RoomMemberDetailsPresenter.Factory {
|
||||
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
|
||||
|
|
@ -30,6 +32,7 @@ object RoomMemberModule {
|
|||
roomMemberId = roomMemberId,
|
||||
room = room,
|
||||
userProfilePresenterFactory = userProfilePresenterFactory,
|
||||
encryptionService = encryptionService,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
|
|
@ -24,13 +25,23 @@ import io.element.android.libraries.architecture.AsyncData
|
|||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
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.api.room.roomMembers
|
||||
import io.element.android.libraries.matrix.ui.room.canInviteAsState
|
||||
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentMap
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class RoomMemberListPresenter @AssistedInject constructor(
|
||||
|
|
@ -38,6 +49,7 @@ class RoomMemberListPresenter @AssistedInject constructor(
|
|||
private val roomMemberListDataSource: RoomMemberListDataSource,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
private val roomMembersModerationPresenter: Presenter<RoomMembersModerationState>,
|
||||
private val encryptionService: EncryptionService,
|
||||
@Assisted private val navigator: RoomMemberListNavigator,
|
||||
) : Presenter<RoomMemberListState> {
|
||||
@AssistedFactory
|
||||
|
|
@ -60,12 +72,20 @@ class RoomMemberListPresenter @AssistedInject constructor(
|
|||
|
||||
val roomModerationState = roomMembersModerationPresenter.present()
|
||||
|
||||
val roomMemberIdentityStates by produceState(persistentMapOf<UserId, IdentityState>()) {
|
||||
room.roomMemberIdentityStateChange()
|
||||
.onEach { identities ->
|
||||
value = identities.associateBy({ it.identityRoomMember.userId }, { it.identityState }).toPersistentMap()
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
|
||||
// Ensure we load the latest data when entering this screen
|
||||
LaunchedEffect(Unit) {
|
||||
room.updateMembers()
|
||||
}
|
||||
|
||||
LaunchedEffect(membersState) {
|
||||
LaunchedEffect(membersState, roomMemberIdentityStates) {
|
||||
if (membersState is MatrixRoomMembersState.Unknown) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
|
@ -84,11 +104,17 @@ class RoomMemberListPresenter @AssistedInject constructor(
|
|||
return@withContext
|
||||
}
|
||||
val result = RoomMembers(
|
||||
invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
|
||||
invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList())
|
||||
.map { it.withIdentityState(roomMemberIdentityStates) }
|
||||
.toImmutableList(),
|
||||
joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList())
|
||||
.sortedWith(PowerLevelRoomMemberComparator())
|
||||
.map { it.withIdentityState(roomMemberIdentityStates) }
|
||||
.toImmutableList(),
|
||||
banned = members.getOrDefault(RoomMembershipState.BAN, emptyList())
|
||||
.sortedBy { it.userId.value }
|
||||
.map { it.withIdentityState(roomMemberIdentityStates) }
|
||||
.toImmutableList(),
|
||||
banned = members.getOrDefault(RoomMembershipState.BAN, emptyList()).sortedBy { it.userId.value }.toImmutableList(),
|
||||
)
|
||||
roomMembers = if (membersState is MatrixRoomMembersState.Pending) {
|
||||
AsyncData.Loading(result)
|
||||
|
|
@ -108,11 +134,17 @@ class RoomMemberListPresenter @AssistedInject constructor(
|
|||
SearchBarResultState.NoResultsFound()
|
||||
} else {
|
||||
val result = RoomMembers(
|
||||
invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
|
||||
invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList())
|
||||
.map { it.withIdentityState(roomMemberIdentityStates) }
|
||||
.toImmutableList(),
|
||||
joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList())
|
||||
.sortedWith(PowerLevelRoomMemberComparator())
|
||||
.map { it.withIdentityState(roomMemberIdentityStates) }
|
||||
.toImmutableList(),
|
||||
banned = results.getOrDefault(RoomMembershipState.BAN, emptyList())
|
||||
.sortedBy { it.userId.value }
|
||||
.map { it.withIdentityState(roomMemberIdentityStates) }
|
||||
.toImmutableList(),
|
||||
banned = results.getOrDefault(RoomMembershipState.BAN, emptyList()).sortedBy { it.userId.value }.toImmutableList(),
|
||||
)
|
||||
SearchBarResultState.Results(
|
||||
if (membersState is MatrixRoomMembersState.Pending) {
|
||||
|
|
@ -149,4 +181,13 @@ class RoomMemberListPresenter @AssistedInject constructor(
|
|||
eventSink = { handleEvents(it) },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun RoomMember.withIdentityState(identityStates: ImmutableMap<UserId, IdentityState>): RoomMemberWithIdentityState {
|
||||
return if (!room.isEncrypted) {
|
||||
RoomMemberWithIdentityState(this, null)
|
||||
} else {
|
||||
val identityState = identityStates[userId] ?: encryptionService.getUserIdentity(userId).getOrNull()
|
||||
RoomMemberWithIdentityState(this, identityState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ package io.element.android.features.roomdetails.impl.members
|
|||
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
|
|
@ -24,7 +25,12 @@ data class RoomMemberListState(
|
|||
)
|
||||
|
||||
data class RoomMembers(
|
||||
val invited: ImmutableList<RoomMember>,
|
||||
val joined: ImmutableList<RoomMember>,
|
||||
val banned: ImmutableList<RoomMember>,
|
||||
val invited: ImmutableList<RoomMemberWithIdentityState>,
|
||||
val joined: ImmutableList<RoomMemberWithIdentityState>,
|
||||
val banned: ImmutableList<RoomMemberWithIdentityState>,
|
||||
)
|
||||
|
||||
data class RoomMemberWithIdentityState(
|
||||
val roomMember: RoomMember,
|
||||
val identityState: IdentityState?,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import io.element.android.features.roomdetails.impl.members.moderation.aRoomMemb
|
|||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -23,8 +24,21 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
|
|||
aRoomMemberListState(
|
||||
roomMembers = AsyncData.Success(
|
||||
RoomMembers(
|
||||
invited = persistentListOf(aVictor(), aWalter()),
|
||||
joined = persistentListOf(anAlice(), aBob(), aWalter()),
|
||||
invited = persistentListOf(aVictor().withIdentity(), aWalter().withIdentity()),
|
||||
joined = persistentListOf(anAlice().withIdentity(), aBob().withIdentity(), aWalter().withIdentity()),
|
||||
banned = persistentListOf(),
|
||||
)
|
||||
)
|
||||
),
|
||||
aRoomMemberListState(
|
||||
roomMembers = AsyncData.Success(
|
||||
RoomMembers(
|
||||
invited = persistentListOf(aVictor().withIdentity(), aWalter().withIdentity()),
|
||||
joined = persistentListOf(
|
||||
anAlice().withIdentity(identityState = IdentityState.Verified),
|
||||
aBob().withIdentity(identityState = IdentityState.PinViolation),
|
||||
aWalter().withIdentity(identityState = IdentityState.VerificationViolation)
|
||||
),
|
||||
banned = persistentListOf(),
|
||||
)
|
||||
)
|
||||
|
|
@ -40,8 +54,8 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
|
|||
searchResults = SearchBarResultState.Results(
|
||||
AsyncData.Success(
|
||||
RoomMembers(
|
||||
invited = persistentListOf(aVictor()),
|
||||
joined = persistentListOf(anAlice()),
|
||||
invited = persistentListOf(aVictor().withIdentity()),
|
||||
joined = persistentListOf(anAlice().withIdentity()),
|
||||
banned = persistentListOf(),
|
||||
)
|
||||
)
|
||||
|
|
@ -67,9 +81,9 @@ internal class RoomMemberListStateBannedProvider : PreviewParameterProvider<Room
|
|||
invited = persistentListOf(),
|
||||
joined = persistentListOf(),
|
||||
banned = persistentListOf(
|
||||
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice"),
|
||||
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob"),
|
||||
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie"),
|
||||
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice").withIdentity(),
|
||||
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob").withIdentity(),
|
||||
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie").withIdentity(),
|
||||
),
|
||||
)
|
||||
),
|
||||
|
|
@ -81,9 +95,9 @@ internal class RoomMemberListStateBannedProvider : PreviewParameterProvider<Room
|
|||
invited = persistentListOf(),
|
||||
joined = persistentListOf(),
|
||||
banned = persistentListOf(
|
||||
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice"),
|
||||
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob"),
|
||||
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie"),
|
||||
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice").withIdentity(),
|
||||
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob").withIdentity(),
|
||||
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie").withIdentity(),
|
||||
),
|
||||
)
|
||||
),
|
||||
|
|
@ -159,3 +173,5 @@ fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob", role = RoomMember.Rol
|
|||
fun aVictor() = aRoomMember(UserId("@victor:server.org"), "Victor", membership = RoomMembershipState.INVITE)
|
||||
|
||||
fun aWalter() = aRoomMember(UserId("@walter:server.org"), "Walter", membership = RoomMembershipState.INVITE)
|
||||
|
||||
private fun RoomMember.withIdentity(identityState: IdentityState? = null) = RoomMemberWithIdentityState(this, identityState)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import androidx.compose.foundation.clickable
|
|||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -41,6 +42,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.roomdetails.impl.R
|
||||
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationView
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
|
|
@ -49,6 +51,7 @@ import io.element.android.libraries.designsystem.components.button.BackButton
|
|||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBar
|
||||
|
|
@ -57,7 +60,9 @@ import io.element.android.libraries.designsystem.theme.components.SegmentedButto
|
|||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.getBestName
|
||||
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
|
@ -268,7 +273,7 @@ private fun LazyListScope.failureItem(failure: Throwable) {
|
|||
|
||||
private fun LazyListScope.roomMemberListSection(
|
||||
headerText: @Composable (() -> String)?,
|
||||
members: ImmutableList<RoomMember>?,
|
||||
members: ImmutableList<RoomMemberWithIdentityState>?,
|
||||
onMemberSelected: (RoomMember) -> Unit,
|
||||
) {
|
||||
headerText?.let {
|
||||
|
|
@ -284,34 +289,61 @@ private fun LazyListScope.roomMemberListSection(
|
|||
items(members.orEmpty()) { matrixUser ->
|
||||
RoomMemberListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
roomMember = matrixUser,
|
||||
onClick = { onMemberSelected(matrixUser) }
|
||||
roomMemberWithIdentity = matrixUser,
|
||||
onClick = { onMemberSelected(matrixUser.roomMember) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomMemberListItem(
|
||||
roomMember: RoomMember,
|
||||
roomMemberWithIdentity: RoomMemberWithIdentityState,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val roleText = when (roomMember.role) {
|
||||
val roleText = when (roomMemberWithIdentity.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 = roomMember.toMatrixUser(),
|
||||
matrixUser = roomMemberWithIdentity.roomMember.toMatrixUser(),
|
||||
avatarSize = AvatarSize.UserListItem,
|
||||
trailingContent = roleText?.let {
|
||||
@Composable {
|
||||
Text(
|
||||
text = it,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
trailingContent = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when (roomMemberWithIdentity.identityState) {
|
||||
IdentityState.Verified -> {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Verified(),
|
||||
contentDescription = stringResource(CommonStrings.common_verified),
|
||||
tint = ElementTheme.colors.iconSuccessPrimary
|
||||
)
|
||||
}
|
||||
IdentityState.VerificationViolation -> {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.ErrorSolid(),
|
||||
contentDescription = stringResource(
|
||||
CommonStrings.crypto_identity_change_profile_pin_violation,
|
||||
roomMemberWithIdentity.roomMember.getBestName()
|
||||
),
|
||||
tint = ElementTheme.colors.iconCriticalPrimary
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
roleText?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class RoomMemberDetailsNode @AssistedInject constructor(
|
|||
presenterFactory: RoomMemberDetailsPresenter.Factory,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
data class RoomMemberDetailsInput(
|
||||
val roomMemberId: UserId
|
||||
val roomMemberId: UserId,
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs = inputs<RoomMemberDetailsInput>()
|
||||
|
|
|
|||
|
|
@ -11,14 +11,25 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.userprofile.api.UserProfileEvents
|
||||
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
|
||||
import io.element.android.features.userprofile.api.UserProfileState
|
||||
import io.element.android.features.userprofile.api.UserProfileVerificationState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
|
||||
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Presenter for room member details screen.
|
||||
|
|
@ -27,6 +38,7 @@ import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
|
|||
class RoomMemberDetailsPresenter @AssistedInject constructor(
|
||||
@Assisted private val roomMemberId: UserId,
|
||||
private val room: MatrixRoom,
|
||||
private val encryptionService: EncryptionService,
|
||||
userProfilePresenterFactory: UserProfilePresenterFactory,
|
||||
) : Presenter<UserProfileState> {
|
||||
interface Factory {
|
||||
|
|
@ -37,6 +49,8 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
|||
|
||||
@Composable
|
||||
override fun present(): UserProfileState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val roomMember by room.getRoomMemberAsState(roomMemberId)
|
||||
LaunchedEffect(Unit) {
|
||||
// Update room member info when opening this screen
|
||||
|
|
@ -60,9 +74,53 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
|||
|
||||
val userProfileState = userProfilePresenter.present()
|
||||
|
||||
val identityStateChanges by produceState<IdentityStateChange?>(initialValue = null) {
|
||||
if (room.isEncrypted) {
|
||||
// Fetch the initial identity state manually
|
||||
val identityState = encryptionService.getUserIdentity(roomMemberId).getOrNull()
|
||||
value = identityState?.let { IdentityStateChange(roomMemberId, it) }
|
||||
|
||||
// Subscribe to the identity changes
|
||||
room.roomMemberIdentityStateChange()
|
||||
.map { it.find { it.identityRoomMember.userId == roomMemberId } }
|
||||
.map { roomMemberIdentityStateChange ->
|
||||
// If we didn't receive any info, manually fetch it
|
||||
roomMemberIdentityStateChange?.identityState ?: encryptionService.getUserIdentity(roomMemberId).getOrNull()
|
||||
}
|
||||
.filterNotNull()
|
||||
.collect { value = IdentityStateChange(roomMemberId, it) }
|
||||
}
|
||||
}
|
||||
|
||||
val verificationState = remember(identityStateChanges) {
|
||||
when (identityStateChanges?.identityState) {
|
||||
IdentityState.VerificationViolation -> UserProfileVerificationState.VERIFICATION_VIOLATION
|
||||
IdentityState.Verified -> UserProfileVerificationState.VERIFIED
|
||||
IdentityState.Pinned, IdentityState.PinViolation -> UserProfileVerificationState.UNVERIFIED
|
||||
else -> UserProfileVerificationState.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
fun eventSink(event: UserProfileEvents) {
|
||||
when (event) {
|
||||
UserProfileEvents.WithdrawVerification -> coroutineScope.launch {
|
||||
encryptionService.withdrawVerification(roomMemberId)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
return userProfileState.copy(
|
||||
userName = roomUserName ?: userProfileState.userName,
|
||||
avatarUrl = roomUserAvatar ?: userProfileState.avatarUrl,
|
||||
verificationState = verificationState,
|
||||
eventSink = { event ->
|
||||
if (event is UserProfileEvents.WithdrawVerification) {
|
||||
eventSink(UserProfileEvents.WithdrawVerification)
|
||||
} else {
|
||||
userProfileState.eventSink(event)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,6 @@
|
|||
package io.element.android.features.roomdetails.impl
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.TurbineTestContext
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.Interaction
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||
|
|
@ -39,6 +35,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_TOPIC
|
|||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
|
|
@ -52,7 +49,7 @@ import io.element.android.tests.testutils.lambda.lambdaError
|
|||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import io.element.android.tests.testutils.withFakeLifecycleOwner
|
||||
import io.element.android.tests.testutils.testWithLifecycleOwner
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
|
|
@ -83,6 +80,7 @@ class RoomDetailsPresenterTest {
|
|||
)
|
||||
),
|
||||
isPinnedMessagesFeatureEnabled: Boolean = true,
|
||||
encryptionService: FakeEncryptionService = FakeEncryptionService(),
|
||||
): RoomDetailsPresenter {
|
||||
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
|
||||
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
|
||||
|
|
@ -93,6 +91,7 @@ class RoomDetailsPresenterTest {
|
|||
userProfilePresenterFactory = {
|
||||
Presenter { aUserProfileState() }
|
||||
},
|
||||
encryptionService = encryptionService,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -110,14 +109,6 @@ class RoomDetailsPresenterTest {
|
|||
)
|
||||
}
|
||||
|
||||
private suspend fun RoomDetailsPresenter.test(validate: suspend TurbineTestContext<RoomDetailsState>.() -> Unit) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
withFakeLifecycleOwner(fakeLifecycleOwner) {
|
||||
present()
|
||||
}
|
||||
}.test(validate = validate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state is created from room if roomInfo is null`() = runTest {
|
||||
val room = aMatrixRoom(
|
||||
|
|
@ -126,7 +117,7 @@ class RoomDetailsPresenterTest {
|
|||
canSendStateResult = { _, _ -> Result.success(true) },
|
||||
)
|
||||
val presenter = createRoomDetailsPresenter(room)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.roomId).isEqualTo(room.roomId)
|
||||
|
|
@ -157,7 +148,7 @@ class RoomDetailsPresenterTest {
|
|||
givenRoomInfo(roomInfo)
|
||||
}
|
||||
val presenter = createRoomDetailsPresenter(room)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
skipItems(1)
|
||||
val updatedState = awaitItem()
|
||||
assertThat(updatedState.roomName).isEqualTo(roomInfo.name)
|
||||
|
|
@ -177,7 +168,7 @@ class RoomDetailsPresenterTest {
|
|||
canSendStateResult = { _, _ -> Result.success(true) },
|
||||
)
|
||||
val presenter = createRoomDetailsPresenter(room)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.roomName).isEqualTo(room.displayName)
|
||||
|
||||
|
|
@ -207,7 +198,7 @@ class RoomDetailsPresenterTest {
|
|||
givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
|
||||
}
|
||||
val presenter = createRoomDetailsPresenter(room)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.roomType).isEqualTo(
|
||||
RoomDetailsType.Dm(
|
||||
|
|
@ -227,7 +218,7 @@ class RoomDetailsPresenterTest {
|
|||
canSendStateResult = { _, _ -> Result.success(true) },
|
||||
)
|
||||
val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
// Initially false
|
||||
assertThat(awaitItem().canInvite).isFalse()
|
||||
// Then the asynchronous check completes and it becomes true
|
||||
|
|
@ -245,7 +236,7 @@ class RoomDetailsPresenterTest {
|
|||
canSendStateResult = { _, _ -> Result.success(true) },
|
||||
)
|
||||
val presenter = createRoomDetailsPresenter(room)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
assertThat(awaitItem().canInvite).isFalse()
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
|
|
@ -260,7 +251,7 @@ class RoomDetailsPresenterTest {
|
|||
canSendStateResult = { _, _ -> Result.success(true) },
|
||||
)
|
||||
val presenter = createRoomDetailsPresenter(room)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
assertThat(awaitItem().canInvite).isFalse()
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
|
|
@ -281,7 +272,7 @@ class RoomDetailsPresenterTest {
|
|||
canUserJoinCallResult = { Result.success(true) },
|
||||
)
|
||||
val presenter = createRoomDetailsPresenter(room)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
// Initially false
|
||||
assertThat(awaitItem().canEdit).isFalse()
|
||||
// Then the asynchronous check completes and it becomes true
|
||||
|
|
@ -320,7 +311,7 @@ class RoomDetailsPresenterTest {
|
|||
givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
|
||||
}
|
||||
val presenter = createRoomDetailsPresenter(room)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
// Initially false
|
||||
assertThat(awaitItem().canEdit).isFalse()
|
||||
// Then the asynchronous check completes, but editing is still disallowed because it's a DM
|
||||
|
|
@ -364,7 +355,7 @@ class RoomDetailsPresenterTest {
|
|||
}
|
||||
|
||||
val presenter = createRoomDetailsPresenter(room)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
skipItems(1)
|
||||
|
||||
// There's no topic, so we hide the entire UI for DMs
|
||||
|
|
@ -391,7 +382,7 @@ class RoomDetailsPresenterTest {
|
|||
canUserJoinCallResult = { Result.success(true) },
|
||||
)
|
||||
val presenter = createRoomDetailsPresenter(room)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
// Initially false
|
||||
assertThat(awaitItem().canEdit).isFalse()
|
||||
// Then the asynchronous check completes and it becomes true
|
||||
|
|
@ -418,7 +409,7 @@ class RoomDetailsPresenterTest {
|
|||
canUserJoinCallResult = { Result.success(true) },
|
||||
)
|
||||
val presenter = createRoomDetailsPresenter(room)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
// Initially false, and no further events
|
||||
assertThat(awaitItem().canEdit).isFalse()
|
||||
|
||||
|
|
@ -444,7 +435,7 @@ class RoomDetailsPresenterTest {
|
|||
canUserJoinCallResult = { Result.success(true) },
|
||||
)
|
||||
val presenter = createRoomDetailsPresenter(room)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
// The initial state is "hidden" and no further state changes happen
|
||||
assertThat(awaitItem().roomTopic).isEqualTo(RoomTopicState.Hidden)
|
||||
|
||||
|
|
@ -472,7 +463,7 @@ class RoomDetailsPresenterTest {
|
|||
givenRoomInfo(aRoomInfo(topic = null))
|
||||
}
|
||||
val presenter = createRoomDetailsPresenter(room)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
// Ignore the initial state
|
||||
skipItems(1)
|
||||
|
||||
|
|
@ -496,7 +487,7 @@ class RoomDetailsPresenterTest {
|
|||
leaveRoomState = aLeaveRoomState(eventSink = leaveRoomEventRecorder),
|
||||
dispatchers = testCoroutineDispatchers()
|
||||
)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
awaitItem().eventSink(RoomDetailsEvent.LeaveRoom)
|
||||
leaveRoomEventRecorder.assertSingle(LeaveRoomEvent.ShowConfirmation(room.roomId))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
|
|
@ -516,7 +507,7 @@ class RoomDetailsPresenterTest {
|
|||
room = room,
|
||||
notificationSettingsService = notificationSettingsService,
|
||||
)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
notificationSettingsService.setRoomNotificationMode(
|
||||
room.roomId,
|
||||
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
|
||||
|
|
@ -545,7 +536,7 @@ class RoomDetailsPresenterTest {
|
|||
room = room,
|
||||
notificationSettingsService = notificationSettingsService
|
||||
)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
awaitItem().eventSink(RoomDetailsEvent.MuteNotification)
|
||||
val updatedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) {
|
||||
it.roomNotificationSettings?.mode == RoomNotificationMode.MUTE
|
||||
|
|
@ -573,7 +564,7 @@ class RoomDetailsPresenterTest {
|
|||
room = room,
|
||||
notificationSettingsService = notificationSettingsService
|
||||
)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
awaitItem().eventSink(RoomDetailsEvent.UnmuteNotification)
|
||||
val updatedState = consumeItemsUntilPredicate {
|
||||
it.roomNotificationSettings?.mode == RoomNotificationMode.ALL_MESSAGES
|
||||
|
|
@ -597,7 +588,7 @@ class RoomDetailsPresenterTest {
|
|||
val analyticsService = FakeAnalyticsService()
|
||||
val presenter =
|
||||
createRoomDetailsPresenter(room = room, analyticsService = analyticsService)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RoomDetailsEvent.SetFavorite(true))
|
||||
setIsFavoriteResult.assertions().isCalledOnce().with(value(true))
|
||||
|
|
@ -623,7 +614,7 @@ class RoomDetailsPresenterTest {
|
|||
canSendStateResult = { _, _ -> Result.success(true) },
|
||||
)
|
||||
val presenter = createRoomDetailsPresenter(room = room)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
room.givenRoomInfo(aRoomInfo(isFavorite = true))
|
||||
consumeItemsUntilPredicate { it.isFavorite }.last().let { state ->
|
||||
assertThat(state.isFavorite).isTrue()
|
||||
|
|
@ -652,19 +643,14 @@ class RoomDetailsPresenterTest {
|
|||
room = room,
|
||||
featureFlagService = featureFlagService,
|
||||
)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
skipItems(1)
|
||||
with(awaitItem()) {
|
||||
assertThat(canShowKnockRequests).isFalse()
|
||||
}
|
||||
assertThat(awaitItem().canShowKnockRequests).isFalse()
|
||||
featureFlagService.setFeatureEnabled(FeatureFlags.Knock, true)
|
||||
with(awaitItem()) {
|
||||
assertThat(canShowKnockRequests).isTrue()
|
||||
}
|
||||
assertThat(awaitItem().canShowKnockRequests).isTrue()
|
||||
room.givenRoomInfo(aRoomInfo(joinRule = JoinRule.Private))
|
||||
with(awaitItem()) {
|
||||
assertThat(canShowKnockRequests).isFalse()
|
||||
}
|
||||
assertThat(awaitItem().canShowKnockRequests).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -677,7 +663,7 @@ class RoomDetailsPresenterTest {
|
|||
)
|
||||
val featureFlagService = FakeFeatureFlagService()
|
||||
val presenter = createRoomDetailsPresenter(room = room, featureFlagService = featureFlagService)
|
||||
presenter.test {
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
skipItems(1)
|
||||
with(awaitItem()) {
|
||||
assertThat(canShowSecurityAndPrivacy).isFalse()
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import io.element.android.libraries.designsystem.theme.components.SearchBarResul
|
|||
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.test.encryption.FakeEncryptionService
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
|
|
@ -59,7 +60,8 @@ class RoomMemberListPresenterTest {
|
|||
skipItems(1)
|
||||
val loadedMembersState = awaitItem()
|
||||
assertThat(loadedMembersState.roomMembers.isLoading()).isFalse()
|
||||
assertThat(loadedMembersState.roomMembers.dataOrNull()?.invited).isEqualTo(listOf(aVictor(), aWalter()))
|
||||
assertThat(loadedMembersState.roomMembers.dataOrNull()?.invited)
|
||||
.isEqualTo(listOf(RoomMemberWithIdentityState(aVictor(), null), RoomMemberWithIdentityState(aWalter(), null)))
|
||||
assertThat(loadedMembersState.roomMembers.dataOrNull()?.joined).isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
|
@ -129,7 +131,7 @@ class RoomMemberListPresenterTest {
|
|||
assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("Alice")
|
||||
val searchSearchResultDelivered = awaitItem()
|
||||
assertThat(searchSearchResultDelivered.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||
assertThat((searchSearchResultDelivered.searchResults as SearchBarResultState.Results).results.dataOrNull()!!.joined.first().displayName)
|
||||
assertThat((searchSearchResultDelivered.searchResults as SearchBarResultState.Results).results.dataOrNull()!!.joined.first().roomMember.displayName)
|
||||
.isEqualTo("Alice")
|
||||
}
|
||||
}
|
||||
|
|
@ -259,11 +261,13 @@ private fun TestScope.createPresenter(
|
|||
),
|
||||
roomMemberListDataSource: RoomMemberListDataSource = createDataSource(coroutineDispatchers = coroutineDispatchers),
|
||||
roomMembersModerationStateLambda: () -> RoomMembersModerationState = { aRoomMembersModerationState() },
|
||||
encryptedService: FakeEncryptionService = FakeEncryptionService(),
|
||||
navigator: RoomMemberListNavigator = object : RoomMemberListNavigator {}
|
||||
) = RoomMemberListPresenter(
|
||||
room = matrixRoom,
|
||||
roomMemberListDataSource = roomMemberListDataSource,
|
||||
coroutineDispatchers = coroutineDispatchers,
|
||||
roomMembersModerationPresenter = { roomMembersModerationStateLambda() },
|
||||
navigator = navigator
|
||||
encryptionService = encryptedService,
|
||||
navigator = navigator,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,14 +13,24 @@ import app.cash.turbine.test
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.roomdetails.impl.aMatrixRoom
|
||||
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
||||
import io.element.android.features.userprofile.api.UserProfileEvents
|
||||
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
|
||||
import io.element.android.features.userprofile.api.UserProfileVerificationState
|
||||
import io.element.android.features.userprofile.shared.aUserProfileState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -45,9 +55,7 @@ class RoomMemberDetailsPresenterTest {
|
|||
val presenter = createRoomMemberDetailsPresenter(
|
||||
room = room,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.userName).isEqualTo("Alice")
|
||||
assertThat(initialState.avatarUrl).isEqualTo("Profile avatar url")
|
||||
|
|
@ -156,6 +164,180 @@ class RoomMemberDetailsPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when user's identity is verified, the value in the state is VERIFIED`() = runTest {
|
||||
val room = FakeMatrixRoom(
|
||||
isEncrypted = true,
|
||||
getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) },
|
||||
userDisplayNameResult = { Result.success("A custom name") },
|
||||
userAvatarUrlResult = { Result.success("A custom avatar") },
|
||||
)
|
||||
val encryptionService = FakeEncryptionService(
|
||||
getUserIdentityResult = { Result.success(IdentityState.Verified) },
|
||||
)
|
||||
val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService)
|
||||
presenter.test {
|
||||
// Initial state, then the verification state is updated
|
||||
assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN)
|
||||
consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.VERIFIED }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when user's identity is unknown, the value in the state is UNKNOWN`() = runTest {
|
||||
val room = FakeMatrixRoom(
|
||||
isEncrypted = true,
|
||||
getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) },
|
||||
userDisplayNameResult = { Result.success("A custom name") },
|
||||
userAvatarUrlResult = { Result.success("A custom avatar") },
|
||||
)
|
||||
val encryptionService = FakeEncryptionService(
|
||||
getUserIdentityResult = { Result.success(null) },
|
||||
)
|
||||
val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService)
|
||||
presenter.test {
|
||||
// Initial state, then the verification state is updated
|
||||
assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN)
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when user's identity is pinned, the value in the state is UNVERIFIED`() = runTest {
|
||||
val room = FakeMatrixRoom(
|
||||
isEncrypted = true,
|
||||
getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) },
|
||||
userDisplayNameResult = { Result.success("A custom name") },
|
||||
userAvatarUrlResult = { Result.success("A custom avatar") },
|
||||
)
|
||||
val encryptionService = FakeEncryptionService(
|
||||
getUserIdentityResult = { Result.success(IdentityState.Pinned) },
|
||||
)
|
||||
val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService)
|
||||
presenter.test {
|
||||
// Initial state, then the verification state is updated
|
||||
assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN)
|
||||
consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.UNVERIFIED }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when user's identity is pin violation, the value in the state is UNVERIFIED`() = runTest {
|
||||
val room = FakeMatrixRoom(
|
||||
isEncrypted = true,
|
||||
getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) },
|
||||
userDisplayNameResult = { Result.success("A custom name") },
|
||||
userAvatarUrlResult = { Result.success("A custom avatar") },
|
||||
)
|
||||
val encryptionService = FakeEncryptionService(
|
||||
getUserIdentityResult = { Result.success(IdentityState.PinViolation) },
|
||||
)
|
||||
val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService)
|
||||
presenter.test {
|
||||
// Initial state, then the verification state is updated
|
||||
assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN)
|
||||
consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.UNVERIFIED }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when user's identity has a verification violation, the value in the state is VERIFICATION_VIOLATION`() = runTest {
|
||||
val room = FakeMatrixRoom(
|
||||
isEncrypted = true,
|
||||
getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) },
|
||||
userDisplayNameResult = { Result.success("A custom name") },
|
||||
userAvatarUrlResult = { Result.success("A custom avatar") },
|
||||
)
|
||||
val encryptionService = FakeEncryptionService(
|
||||
getUserIdentityResult = { Result.success(IdentityState.VerificationViolation) },
|
||||
)
|
||||
val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService)
|
||||
presenter.test {
|
||||
// Initial state, then the verification state is updated
|
||||
assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN)
|
||||
consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.VERIFICATION_VIOLATION }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - user identity updates in real time if the room is encrypted`() = runTest {
|
||||
val room = FakeMatrixRoom(
|
||||
isEncrypted = true,
|
||||
getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) },
|
||||
userDisplayNameResult = { Result.success("A custom name") },
|
||||
userAvatarUrlResult = { Result.success("A custom avatar") },
|
||||
)
|
||||
val encryptionService = FakeEncryptionService(
|
||||
getUserIdentityResult = { Result.success(null) },
|
||||
)
|
||||
val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService)
|
||||
presenter.test {
|
||||
// Initial state, then the verification state is updated
|
||||
assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN)
|
||||
|
||||
room.emitSyncUpdate()
|
||||
|
||||
room.emitIdentityStateChanges(listOf(IdentityStateChange(A_USER_ID, IdentityState.Pinned)))
|
||||
consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.UNVERIFIED }
|
||||
|
||||
room.emitIdentityStateChanges(listOf(IdentityStateChange(A_USER_ID, IdentityState.Verified)))
|
||||
consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.VERIFIED }
|
||||
|
||||
room.emitIdentityStateChanges(listOf(IdentityStateChange(A_USER_ID, IdentityState.VerificationViolation)))
|
||||
consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.VERIFICATION_VIOLATION }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - user identity can't update in real time if the room is not encrypted`() = runTest {
|
||||
val room = FakeMatrixRoom(
|
||||
isEncrypted = false,
|
||||
getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) },
|
||||
userDisplayNameResult = { Result.success("A custom name") },
|
||||
userAvatarUrlResult = { Result.success("A custom avatar") },
|
||||
)
|
||||
val encryptionService = FakeEncryptionService(
|
||||
getUserIdentityResult = { Result.success(null) },
|
||||
)
|
||||
val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService)
|
||||
presenter.test {
|
||||
// Initial state, then the verification state is updated
|
||||
assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN)
|
||||
|
||||
room.emitSyncUpdate()
|
||||
room.emitIdentityStateChanges(listOf(IdentityStateChange(A_USER_ID, IdentityState.Pinned)))
|
||||
|
||||
// No new events emitted
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handles WithdrawVerification action`() = runTest {
|
||||
val room = FakeMatrixRoom(
|
||||
isEncrypted = true,
|
||||
getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) },
|
||||
userDisplayNameResult = { Result.success("A custom name") },
|
||||
userAvatarUrlResult = { Result.success("A custom avatar") },
|
||||
)
|
||||
val withdrawVerificationResult = lambdaRecorder<UserId, Result<Unit>> { Result.success(Unit) }
|
||||
val encryptionService = FakeEncryptionService(
|
||||
getUserIdentityResult = { Result.success(IdentityState.VerificationViolation) },
|
||||
withdrawVerificationResult = withdrawVerificationResult,
|
||||
)
|
||||
val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService)
|
||||
presenter.test {
|
||||
// Initial state, then the verification state is updated
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN)
|
||||
|
||||
consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.VERIFICATION_VIOLATION }
|
||||
|
||||
initialState.eventSink(UserProfileEvents.WithdrawVerification)
|
||||
withdrawVerificationResult.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRoomMemberDetailsPresenter(
|
||||
room: MatrixRoom,
|
||||
userProfilePresenterFactory: UserProfilePresenterFactory = UserProfilePresenterFactory {
|
||||
|
|
@ -166,11 +348,13 @@ class RoomMemberDetailsPresenterTest {
|
|||
)
|
||||
}
|
||||
},
|
||||
encryptionService: FakeEncryptionService = FakeEncryptionService(getUserIdentityResult = { Result.success(null) }),
|
||||
): RoomMemberDetailsPresenter {
|
||||
return RoomMemberDetailsPresenter(
|
||||
roomMemberId = UserId("@alice:server.org"),
|
||||
room = room,
|
||||
userProfilePresenterFactory = userProfilePresenterFactory
|
||||
userProfilePresenterFactory = userProfilePresenterFactory,
|
||||
encryptionService = encryptionService,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,4 +14,5 @@ sealed interface UserProfileEvents {
|
|||
data class UnblockUser(val needsConfirmation: Boolean = false) : UserProfileEvents
|
||||
data object ClearBlockUserError : UserProfileEvents
|
||||
data object ClearConfirmationDialog : UserProfileEvents
|
||||
data object WithdrawVerification : UserProfileEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ data class UserProfileState(
|
|||
val userId: UserId,
|
||||
val userName: String?,
|
||||
val avatarUrl: String?,
|
||||
val isVerified: AsyncData<Boolean>,
|
||||
val verificationState: UserProfileVerificationState,
|
||||
val isBlocked: AsyncData<Boolean>,
|
||||
val startDmActionState: AsyncAction<RoomId>,
|
||||
val displayConfirmationDialog: ConfirmationDialog?,
|
||||
|
|
@ -30,3 +30,10 @@ data class UserProfileState(
|
|||
Unblock
|
||||
}
|
||||
}
|
||||
|
||||
enum class UserProfileVerificationState {
|
||||
UNKNOWN,
|
||||
VERIFIED,
|
||||
UNVERIFIED,
|
||||
VERIFICATION_VIOLATION,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ class UserProfileFlowNode @AssistedInject constructor(
|
|||
data class VerifyUser(val userId: UserId) : NavTarget
|
||||
}
|
||||
|
||||
private val inputs = inputs<UserProfileEntryPoint.Params>()
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Root -> {
|
||||
|
|
@ -86,7 +88,7 @@ class UserProfileFlowNode @AssistedInject constructor(
|
|||
backstack.push(NavTarget.VerifyUser(userId))
|
||||
}
|
||||
}
|
||||
val params = UserProfileNode.UserProfileInputs(userId = inputs<UserProfileEntryPoint.Params>().userId)
|
||||
val params = UserProfileNode.UserProfileInputs(userId = inputs.userId)
|
||||
createNode<UserProfileNode>(buildContext, listOf(callback, params))
|
||||
}
|
||||
is NavTarget.AvatarPreview -> {
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class UserProfileNode @AssistedInject constructor(
|
|||
|
||||
private val inputs = inputs<UserProfileInputs>()
|
||||
private val callback = inputs<UserProfileNodeHelper.Callback>()
|
||||
private val presenter = presenterFactory.create(inputs.userId)
|
||||
private val presenter = presenterFactory.create(userId = inputs.userId)
|
||||
private val userProfileNodeHelper = UserProfileNodeHelper(inputs.userId)
|
||||
|
||||
init {
|
||||
|
|
|
|||
|
|
@ -24,10 +24,10 @@ import io.element.android.features.createroom.api.StartDMAction
|
|||
import io.element.android.features.userprofile.api.UserProfileEvents
|
||||
import io.element.android.features.userprofile.api.UserProfileState
|
||||
import io.element.android.features.userprofile.api.UserProfileState.ConfirmationDialog
|
||||
import io.element.android.features.userprofile.api.UserProfileVerificationState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
|
@ -75,7 +75,6 @@ class UserProfilePresenter @AssistedInject constructor(
|
|||
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
|
||||
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||
val isBlocked: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
|
||||
val isVerified: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
|
||||
val dmRoomId by getDmRoomId()
|
||||
val canCall by getCanCall(dmRoomId)
|
||||
LaunchedEffect(Unit) {
|
||||
|
|
@ -87,12 +86,6 @@ class UserProfilePresenter @AssistedInject constructor(
|
|||
}
|
||||
val userProfile by produceState<MatrixUser?>(null) { value = client.getProfile(userId).getOrNull() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
suspend {
|
||||
client.encryptionService().isUserVerified(userId).getOrThrow()
|
||||
}.runCatchingUpdatingState(isVerified)
|
||||
}
|
||||
|
||||
fun handleEvents(event: UserProfileEvents) {
|
||||
when (event) {
|
||||
is UserProfileEvents.BlockUser -> {
|
||||
|
|
@ -127,6 +120,8 @@ class UserProfilePresenter @AssistedInject constructor(
|
|||
UserProfileEvents.ClearStartDMState -> {
|
||||
startDmActionState.value = AsyncAction.Uninitialized
|
||||
}
|
||||
// Do nothing for withdrawing verification as it's handled by the RoomMemberDetailsPresenter if needed
|
||||
UserProfileEvents.WithdrawVerification -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -135,7 +130,7 @@ class UserProfilePresenter @AssistedInject constructor(
|
|||
userName = userProfile?.displayName,
|
||||
avatarUrl = userProfile?.avatarUrl,
|
||||
isBlocked = isBlocked.value,
|
||||
isVerified = isVerified.value,
|
||||
verificationState = UserProfileVerificationState.UNKNOWN,
|
||||
startDmActionState = startDmActionState.value,
|
||||
displayConfirmationDialog = confirmationDialog,
|
||||
isCurrentUser = isCurrentUser,
|
||||
|
|
|
|||
|
|
@ -18,12 +18,14 @@ import io.element.android.features.createroom.api.StartDMAction
|
|||
import io.element.android.features.createroom.test.FakeStartDMAction
|
||||
import io.element.android.features.userprofile.api.UserProfileEvents
|
||||
import io.element.android.features.userprofile.api.UserProfileState
|
||||
import io.element.android.features.userprofile.api.UserProfileVerificationState
|
||||
import io.element.android.features.userprofile.impl.root.UserProfilePresenter
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
|
|
@ -69,7 +71,7 @@ class UserProfilePresenterTest {
|
|||
assertThat(initialState.userName).isEqualTo(matrixUser.displayName)
|
||||
assertThat(initialState.avatarUrl).isEqualTo(matrixUser.avatarUrl)
|
||||
assertThat(initialState.isBlocked).isEqualTo(AsyncData.Success(false))
|
||||
assertThat(initialState.isVerified.dataOrNull()).isFalse()
|
||||
assertThat(initialState.verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN)
|
||||
assertThat(initialState.dmRoomId).isEqualTo(A_ROOM_ID)
|
||||
assertThat(initialState.canCall).isFalse()
|
||||
}
|
||||
|
|
@ -361,36 +363,25 @@ class UserProfilePresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when user is verified, the value in the state is true`() = runTest {
|
||||
val client = createFakeMatrixClient(isUserVerified = true)
|
||||
val presenter = createUserProfilePresenter(
|
||||
client = client,
|
||||
)
|
||||
presenter.test {
|
||||
assertThat(awaitItem().isVerified.isUninitialized()).isTrue()
|
||||
assertThat(awaitItem().isVerified.isLoading()).isTrue()
|
||||
assertThat(awaitItem().isVerified.dataOrNull()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||
skipItems(2)
|
||||
skipItems(1)
|
||||
return awaitItem()
|
||||
}
|
||||
|
||||
private fun createFakeMatrixClient(
|
||||
isUserVerified: Boolean = false,
|
||||
isUserVerified: Boolean = true,
|
||||
userIdentityState: IdentityState? = null,
|
||||
ignoreUserResult: (UserId) -> Result<Unit> = { Result.success(Unit) },
|
||||
unIgnoreUserResult: (UserId) -> Result<Unit> = { Result.success(Unit) },
|
||||
ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf())
|
||||
) = FakeMatrixClient(
|
||||
encryptionService = FakeEncryptionService(
|
||||
isUserVerifiedResult = { Result.success(isUserVerified) }
|
||||
isUserVerifiedResult = { Result.success(isUserVerified) },
|
||||
getUserIdentityResult = { Result.success(userIdentityState) }
|
||||
),
|
||||
ignoreUserResult = ignoreUserResult,
|
||||
unIgnoreUserResult = unIgnoreUserResult,
|
||||
ignoredUsersFlow = ignoredUsersFlow
|
||||
ignoredUsersFlow = ignoredUsersFlow,
|
||||
)
|
||||
|
||||
private fun createUserProfilePresenter(
|
||||
|
|
@ -401,7 +392,7 @@ class UserProfilePresenterTest {
|
|||
return UserProfilePresenter(
|
||||
userId = userId,
|
||||
client = client,
|
||||
startDMAction = startDMAction
|
||||
startDMAction = startDMAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.features.userprofile.api.UserProfileVerificationState
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.MatrixBadgeAtom
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRowMolecule
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
|
|
@ -30,6 +30,8 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
|||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.ButtonSize
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
|
|
@ -42,8 +44,9 @@ fun UserProfileHeaderSection(
|
|||
avatarUrl: String?,
|
||||
userId: UserId,
|
||||
userName: String?,
|
||||
isUserVerified: AsyncData<Boolean>,
|
||||
verificationState: UserProfileVerificationState,
|
||||
openAvatarPreview: (url: String) -> Unit,
|
||||
withdrawVerificationClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
|
|
@ -74,16 +77,37 @@ fun UserProfileHeaderSection(
|
|||
color = ElementTheme.colors.textSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (isUserVerified.dataOrNull() == true) {
|
||||
MatrixBadgeRowMolecule(
|
||||
data = listOf(
|
||||
MatrixBadgeAtom.MatrixBadgeData(
|
||||
text = stringResource(CommonStrings.common_verified),
|
||||
icon = CompoundIcons.Verified(),
|
||||
type = MatrixBadgeAtom.Type.Positive,
|
||||
)
|
||||
).toImmutableList(),
|
||||
)
|
||||
when (verificationState) {
|
||||
UserProfileVerificationState.UNKNOWN, UserProfileVerificationState.UNVERIFIED -> Unit
|
||||
UserProfileVerificationState.VERIFIED -> {
|
||||
MatrixBadgeRowMolecule(
|
||||
data = listOf(
|
||||
MatrixBadgeAtom.MatrixBadgeData(
|
||||
text = stringResource(CommonStrings.common_verified),
|
||||
icon = CompoundIcons.Verified(),
|
||||
type = MatrixBadgeAtom.Type.Positive,
|
||||
)
|
||||
).toImmutableList(),
|
||||
)
|
||||
}
|
||||
UserProfileVerificationState.VERIFICATION_VIOLATION -> {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(CommonStrings.crypto_identity_change_profile_pin_violation, userName ?: userId.value),
|
||||
color = ElementTheme.colors.textCriticalPrimary,
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
size = ButtonSize.MediumLowPadding,
|
||||
text = stringResource(CommonStrings.crypto_identity_change_withdraw_verification_action),
|
||||
onClick = withdrawVerificationClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(40.dp))
|
||||
}
|
||||
|
|
@ -96,7 +120,21 @@ internal fun UserProfileHeaderSectionPreview() = ElementPreview {
|
|||
avatarUrl = null,
|
||||
userId = UserId("@alice:example.com"),
|
||||
userName = "Alice",
|
||||
isUserVerified = AsyncData.Success(true),
|
||||
verificationState = UserProfileVerificationState.VERIFIED,
|
||||
openAvatarPreview = {},
|
||||
withdrawVerificationClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun UserProfileHeaderSectionWithVerificationViolationPreview() = ElementPreview {
|
||||
UserProfileHeaderSection(
|
||||
avatarUrl = null,
|
||||
userId = UserId("@alice:example.com"),
|
||||
userName = "Alice",
|
||||
verificationState = UserProfileVerificationState.VERIFICATION_VIOLATION,
|
||||
openAvatarPreview = {},
|
||||
withdrawVerificationClick = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
|||
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
|
||||
import io.element.android.features.userprofile.api.UserProfileEvents
|
||||
import io.element.android.features.userprofile.api.UserProfileState
|
||||
import io.element.android.features.userprofile.api.UserProfileVerificationState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
|
@ -22,13 +23,14 @@ open class UserProfileStateProvider : PreviewParameterProvider<UserProfileState>
|
|||
get() = sequenceOf(
|
||||
aUserProfileState(),
|
||||
aUserProfileState(userName = null),
|
||||
aUserProfileState(isBlocked = AsyncData.Success(true), isVerified = AsyncData.Success(true)),
|
||||
aUserProfileState(isBlocked = AsyncData.Success(true), verificationState = UserProfileVerificationState.VERIFIED),
|
||||
aUserProfileState(displayConfirmationDialog = UserProfileState.ConfirmationDialog.Block),
|
||||
aUserProfileState(displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock),
|
||||
aUserProfileState(isBlocked = AsyncData.Loading(true), isVerified = AsyncData.Loading()),
|
||||
aUserProfileState(isBlocked = AsyncData.Loading(true), verificationState = UserProfileVerificationState.UNKNOWN),
|
||||
aUserProfileState(startDmActionState = AsyncAction.Loading),
|
||||
aUserProfileState(canCall = true),
|
||||
aUserProfileState(startDmActionState = ConfirmingStartDmWithMatrixUser(aMatrixUser())),
|
||||
aUserProfileState(verificationState = UserProfileVerificationState.VERIFICATION_VIOLATION),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +39,7 @@ fun aUserProfileState(
|
|||
userName: String? = "Daniel",
|
||||
avatarUrl: String? = null,
|
||||
isBlocked: AsyncData<Boolean> = AsyncData.Success(false),
|
||||
isVerified: AsyncData<Boolean> = AsyncData.Success(false),
|
||||
verificationState: UserProfileVerificationState = UserProfileVerificationState.UNVERIFIED,
|
||||
startDmActionState: AsyncAction<RoomId> = AsyncAction.Uninitialized,
|
||||
displayConfirmationDialog: UserProfileState.ConfirmationDialog? = null,
|
||||
isCurrentUser: Boolean = false,
|
||||
|
|
@ -49,7 +51,7 @@ fun aUserProfileState(
|
|||
userName = userName,
|
||||
avatarUrl = avatarUrl,
|
||||
isBlocked = isBlocked,
|
||||
isVerified = isVerified,
|
||||
verificationState = verificationState,
|
||||
startDmActionState = startDmActionState,
|
||||
displayConfirmationDialog = displayConfirmationDialog,
|
||||
isCurrentUser = isCurrentUser,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
|
|||
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
|
||||
import io.element.android.features.userprofile.api.UserProfileEvents
|
||||
import io.element.android.features.userprofile.api.UserProfileState
|
||||
import io.element.android.features.userprofile.api.UserProfileVerificationState
|
||||
import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs
|
||||
import io.element.android.features.userprofile.shared.blockuser.BlockUserSection
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||
|
|
@ -70,10 +71,11 @@ fun UserProfileView(
|
|||
avatarUrl = state.avatarUrl,
|
||||
userId = state.userId,
|
||||
userName = state.userName,
|
||||
isUserVerified = state.isVerified,
|
||||
verificationState = state.verificationState,
|
||||
openAvatarPreview = { avatarUrl ->
|
||||
openAvatarPreview(state.userName ?: state.userId.value, avatarUrl)
|
||||
},
|
||||
withdrawVerificationClick = { state.eventSink(UserProfileEvents.WithdrawVerification) },
|
||||
)
|
||||
UserProfileMainActionsSection(
|
||||
isCurrentUser = state.isCurrentUser,
|
||||
|
|
@ -122,7 +124,7 @@ private fun VerifyUserSection(
|
|||
state: UserProfileState,
|
||||
onVerifyClick: () -> Unit,
|
||||
) {
|
||||
if (state.isVerified.dataOrNull() == false) {
|
||||
if (state.verificationState == UserProfileVerificationState.UNVERIFIED) {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(CommonStrings.common_verify_user)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import androidx.compose.ui.test.performClick
|
|||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.userprofile.api.UserProfileEvents
|
||||
import io.element.android.features.userprofile.api.UserProfileState
|
||||
import io.element.android.features.userprofile.api.UserProfileVerificationState
|
||||
import io.element.android.features.userprofile.shared.R
|
||||
import io.element.android.features.userprofile.shared.UserProfileView
|
||||
import io.element.android.features.userprofile.shared.aUserProfileState
|
||||
|
|
@ -200,7 +201,7 @@ class UserProfileViewTest {
|
|||
fun `on verify user clicked - the right callback is called`() = runTest {
|
||||
ensureCalledOnceWithParam(A_USER_ID) { callback ->
|
||||
rule.setUserProfileView(
|
||||
state = aUserProfileState(userId = A_USER_ID, isVerified = AsyncData.Success(false)),
|
||||
state = aUserProfileState(userId = A_USER_ID, verificationState = UserProfileVerificationState.UNVERIFIED),
|
||||
onVerifyClick = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.common_verify_user)
|
||||
|
|
|
|||
|
|
@ -187,6 +187,9 @@ class IncomingVerificationPresenterTest {
|
|||
fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidCancel)
|
||||
// The screen is dismissed
|
||||
skipItems(2)
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
onFinishLambda.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ package io.element.android.libraries.designsystem.components.list
|
|||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -77,8 +79,9 @@ sealed interface ListItemContent {
|
|||
/**
|
||||
* Default Icon content for [ListItem]. Sets the Icon component to a predefined size.
|
||||
* @param iconSource The icon to display, using [IconSource.getPainter].
|
||||
* @param tintColor The tint color for the icon, if any. Defaults to `null`.
|
||||
*/
|
||||
data class Icon(val iconSource: IconSource) : ListItemContent
|
||||
data class Icon(val iconSource: IconSource, val tintColor: Color? = null) : ListItemContent
|
||||
|
||||
/**
|
||||
* Default Text content for [ListItem]. Sets the Text component to a max size and clips overflow.
|
||||
|
|
@ -119,7 +122,8 @@ sealed interface ListItemContent {
|
|||
IconComponent(
|
||||
modifier = Modifier.size(maxCompactSize),
|
||||
painter = iconSource.getPainter(),
|
||||
contentDescription = iconSource.contentDescription
|
||||
contentDescription = iconSource.contentDescription,
|
||||
tint = tintColor ?: LocalContentColor.current,
|
||||
)
|
||||
}
|
||||
is Text -> TextComponent(modifier = Modifier.widthIn(max = 128.dp), text = text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
package io.element.android.libraries.matrix.api.encryption
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
|
|
@ -75,6 +76,11 @@ interface EncryptionService {
|
|||
* user trust from verified to TOFU verified.
|
||||
*/
|
||||
suspend fun withdrawVerification(userId: UserId): Result<Unit>
|
||||
|
||||
/**
|
||||
* Get the identity state of a user, if known.
|
||||
*/
|
||||
suspend fun getUserIdentity(userId: UserId): Result<IdentityState?>
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
|
|||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
import io.element.android.libraries.matrix.impl.sync.RustSyncService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -202,18 +203,29 @@ internal class RustEncryptionService(
|
|||
}
|
||||
|
||||
override suspend fun isUserVerified(userId: UserId): Result<Boolean> = runCatching {
|
||||
getUserIdentity(userId).isVerified()
|
||||
getUserIdentityInternal(userId).isVerified()
|
||||
}
|
||||
|
||||
override suspend fun pinUserIdentity(userId: UserId): Result<Unit> = runCatching {
|
||||
getUserIdentity(userId).pin()
|
||||
getUserIdentityInternal(userId).pin()
|
||||
}
|
||||
|
||||
override suspend fun withdrawVerification(userId: UserId): Result<Unit> = runCatching {
|
||||
getUserIdentity(userId).withdrawVerification()
|
||||
getUserIdentityInternal(userId).withdrawVerification()
|
||||
}
|
||||
|
||||
private suspend fun getUserIdentity(userId: UserId): UserIdentity {
|
||||
override suspend fun getUserIdentity(userId: UserId): Result<IdentityState?> = runCatching {
|
||||
val identity = getUserIdentityInternal(userId)
|
||||
val isVerified = identity.isVerified()
|
||||
when {
|
||||
identity.hasVerificationViolation() -> IdentityState.VerificationViolation
|
||||
isVerified -> IdentityState.Verified
|
||||
!isVerified -> IdentityState.Pinned
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getUserIdentityInternal(userId: UserId): UserIdentity {
|
||||
return service.userIdentity(
|
||||
userId = userId.value,
|
||||
// requestFromHomeserverIfNeeded = true,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
|
|||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
|
@ -25,6 +26,7 @@ class FakeEncryptionService(
|
|||
private val pinUserIdentityResult: (UserId) -> Result<Unit> = { lambdaError() },
|
||||
private val isUserVerifiedResult: (UserId) -> Result<Boolean> = { lambdaError() },
|
||||
private val withdrawVerificationResult: (UserId) -> Result<Unit> = { lambdaError() },
|
||||
private val getUserIdentityResult: (UserId) -> Result<IdentityState?> = { lambdaError() }
|
||||
) : EncryptionService {
|
||||
private var disableRecoveryFailure: Exception? = null
|
||||
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
|
||||
|
|
@ -133,6 +135,10 @@ class FakeEncryptionService(
|
|||
isUserVerifiedResult(userId)
|
||||
}
|
||||
|
||||
override suspend fun getUserIdentity(userId: UserId): Result<IdentityState?> = simulateLongTask {
|
||||
return getUserIdentityResult(userId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FAKE_RECOVERY_KEY = "fake"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,21 +5,22 @@
|
|||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.messagecomposer
|
||||
package io.element.android.libraries.matrix.ui.room
|
||||
|
||||
import androidx.compose.runtime.ProduceStateScope
|
||||
import io.element.android.features.messages.impl.crypto.identity.IdentityRoomMember
|
||||
import io.element.android.features.messages.impl.crypto.identity.RoomMemberIdentityStateChange
|
||||
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.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.PersistentList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
|
|
@ -28,30 +29,33 @@ import kotlinx.coroutines.flow.launchIn
|
|||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun ProduceStateScope<PersistentList<RoomMemberIdentityStateChange>>.observeRoomMemberIdentityStateChange(room: MatrixRoom) {
|
||||
room.syncUpdateFlow
|
||||
fun MatrixRoom.roomMemberIdentityStateChange(): Flow<ImmutableList<RoomMemberIdentityStateChange>> {
|
||||
return syncUpdateFlow
|
||||
.filter {
|
||||
// Room cannot become unencrypted, so we can just apply a filter here.
|
||||
room.isEncrypted
|
||||
isEncrypted
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest {
|
||||
combine(room.identityStateChangesFlow, room.membersStateFlow) { identityStateChanges, membersState ->
|
||||
combine(identityStateChangesFlow, membersStateFlow) { identityStateChanges, membersState ->
|
||||
identityStateChanges.map { identityStateChange ->
|
||||
val member = membersState.roomMembers()
|
||||
?.firstOrNull { roomMember -> roomMember.userId == identityStateChange.userId }
|
||||
?.find { roomMember -> roomMember.userId == identityStateChange.userId }
|
||||
?.toIdentityRoomMember()
|
||||
?: createDefaultRoomMemberForIdentityChange(identityStateChange.userId)
|
||||
RoomMemberIdentityStateChange(
|
||||
identityRoomMember = member,
|
||||
identityState = identityStateChange.identityState,
|
||||
)
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.onEach { roomMemberIdentityStateChanges ->
|
||||
value = roomMemberIdentityStateChanges.toPersistentList()
|
||||
}
|
||||
}.toPersistentList()
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
}
|
||||
|
||||
fun ProduceStateScope<PersistentList<RoomMemberIdentityStateChange>>.observeRoomMemberIdentityStateChange(room: MatrixRoom) {
|
||||
room.roomMemberIdentityStateChange()
|
||||
.onEach { roomMemberIdentityStateChanges ->
|
||||
value = roomMemberIdentityStateChanges.toPersistentList()
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
|
|
@ -72,3 +76,14 @@ private fun createDefaultRoomMemberForIdentityChange(userId: UserId) = IdentityR
|
|||
size = AvatarSize.ComposerAlert,
|
||||
),
|
||||
)
|
||||
|
||||
data class RoomMemberIdentityStateChange(
|
||||
val identityRoomMember: IdentityRoomMember,
|
||||
val identityState: IdentityState,
|
||||
)
|
||||
|
||||
data class IdentityRoomMember(
|
||||
val userId: UserId,
|
||||
val displayNameOrDefault: String,
|
||||
val avatarData: AvatarData,
|
||||
)
|
||||
|
|
@ -129,6 +129,7 @@ class KonsistPreviewTest {
|
|||
"TimelineVideoWithCaptionRowPreview",
|
||||
"TimelineViewMessageShieldPreview",
|
||||
"UserAvatarColorsPreview",
|
||||
"UserProfileHeaderSectionWithVerificationViolationPreview",
|
||||
"VoiceItemViewPlayPreview",
|
||||
)
|
||||
.assertTrue(
|
||||
|
|
|
|||
|
|
@ -9,31 +9,64 @@ package io.element.android.tests.testutils
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.InternalComposeApi
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.currentComposer
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.TurbineTestContext
|
||||
import app.cash.turbine.test
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
|
||||
/**
|
||||
* Composable that provides a fake [LifecycleOwner] to the composition.
|
||||
*
|
||||
* **WARNING: DO NOT USE OUTSIDE TESTS.**
|
||||
*/
|
||||
@OptIn(InternalComposeApi::class)
|
||||
@Stable
|
||||
@Composable
|
||||
fun <T> withFakeLifecycleOwner(lifecycleOwner: FakeLifecycleOwner = FakeLifecycleOwner(), block: @Composable () -> T): T {
|
||||
var state: T? by remember { mutableStateOf(null) }
|
||||
CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) {
|
||||
state = block()
|
||||
}
|
||||
return state!!
|
||||
fun <T> withFakeLifecycleOwner(
|
||||
lifecycleOwner: FakeLifecycleOwner = FakeLifecycleOwner(),
|
||||
block: @Composable () -> T
|
||||
): T {
|
||||
currentComposer.startProvider(LocalLifecycleOwner provides lifecycleOwner)
|
||||
val state = block()
|
||||
currentComposer.endProvider()
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a [Presenter] with a fake [LifecycleOwner].
|
||||
*
|
||||
* **WARNING: DO NOT USE OUTSIDE TESTS.**
|
||||
*/
|
||||
suspend fun <T> Presenter<T>.testWithLifecycleOwner(
|
||||
lifecycleOwner: FakeLifecycleOwner = FakeLifecycleOwner(),
|
||||
block: suspend TurbineTestContext<T>.() -> Unit
|
||||
) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
val ret = withFakeLifecycleOwner(lifecycleOwner) {
|
||||
present()
|
||||
}
|
||||
ret
|
||||
}.test<T>(validate = block)
|
||||
}
|
||||
|
||||
@SuppressLint("VisibleForTests")
|
||||
class FakeLifecycleOwner : LifecycleOwner {
|
||||
class FakeLifecycleOwner(initialState: Lifecycle.State? = null) : LifecycleOwner {
|
||||
override val lifecycle: Lifecycle = LifecycleRegistry.createUnsafe(this)
|
||||
|
||||
init {
|
||||
initialState?.let { givenState(it) }
|
||||
}
|
||||
|
||||
fun givenState(state: Lifecycle.State) {
|
||||
(lifecycle as LifecycleRegistry).currentState = state
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4d2c2a4712004673371795fd640ed468956839772f0aba9934702ac72c8d608a
|
||||
size 68313
|
||||
oid sha256:98b454bc6221fd8c49ebea5ad56f61068a2c4cde74b3d0f1c0525087c19628f5
|
||||
size 65685
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6e5c2c07576f39ce1ac227625fce1751f260aa663726087ca11f51407988469a
|
||||
size 70630
|
||||
oid sha256:e388d6d9cfddaf48d83ee50b09c17a44673830a0e18ec97335acbf454c4ceb32
|
||||
size 68924
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:250111eb95059839f71392dbfe683e7141825bb901b880ab402a412eb0a2edc0
|
||||
size 60477
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:70f7e53cdeebda6fcbf0b9e542323eff9333f01acb822607b1bfeb784a5a61c2
|
||||
size 59960
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2350a560fcc78a3d1b22cd235746ac03b46eee2912184dbbe62683540dfee2b4
|
||||
size 11704
|
||||
oid sha256:a532a73c1c1d7c72503ca75f3f9863a3e4e073a0cc780ec05483e5c3001bd405
|
||||
size 45665
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b730a39ec0ac14e8557e738dd13847b5bb1291713b0d013cf50563a1e0904f7c
|
||||
size 12660
|
||||
oid sha256:2350a560fcc78a3d1b22cd235746ac03b46eee2912184dbbe62683540dfee2b4
|
||||
size 11704
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2350a560fcc78a3d1b22cd235746ac03b46eee2912184dbbe62683540dfee2b4
|
||||
size 11704
|
||||
oid sha256:b730a39ec0ac14e8557e738dd13847b5bb1291713b0d013cf50563a1e0904f7c
|
||||
size 12660
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d175ae95fed4ac24e9b506678c4ee1e235c3c8405915498f7f97db0764e5470c
|
||||
size 7571
|
||||
oid sha256:2350a560fcc78a3d1b22cd235746ac03b46eee2912184dbbe62683540dfee2b4
|
||||
size 11704
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0db80941c8c981d3f66bc6cd9364f0f8fd5b1e7033f81da46cdffe478f97c748
|
||||
size 6528
|
||||
oid sha256:d175ae95fed4ac24e9b506678c4ee1e235c3c8405915498f7f97db0764e5470c
|
||||
size 7571
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ddb463eff5c3174663e6543726671708cef04bb11c9194b20373435e1441323a
|
||||
size 24543
|
||||
oid sha256:0db80941c8c981d3f66bc6cd9364f0f8fd5b1e7033f81da46cdffe478f97c748
|
||||
size 6528
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:57ee28833b587ec86f93c685d716f0cce94b60e0752de3fc1a1a3d7ac05cbdb6
|
||||
size 11013
|
||||
oid sha256:ddb463eff5c3174663e6543726671708cef04bb11c9194b20373435e1441323a
|
||||
size 24543
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bcc0479100b1fba092a109696b548da7a1da26583304ab7273bd454def8046b2
|
||||
size 18091
|
||||
oid sha256:57ee28833b587ec86f93c685d716f0cce94b60e0752de3fc1a1a3d7ac05cbdb6
|
||||
size 11013
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bcc0479100b1fba092a109696b548da7a1da26583304ab7273bd454def8046b2
|
||||
size 18091
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9bee4a7a63485b54555a203b8e4fc557e504b3efacb91d1c7c082647848c8c45
|
||||
size 11051
|
||||
oid sha256:af97cc1cda5eab600630181788cbbd7ce6f4db3c18d6f6b5c44519f1a4e0b1e0
|
||||
size 45622
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:871c608b761d8df820122d9d245ac037f71d227e7bc23f7fbe3402662ff05536
|
||||
size 11938
|
||||
oid sha256:9bee4a7a63485b54555a203b8e4fc557e504b3efacb91d1c7c082647848c8c45
|
||||
size 11051
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9bee4a7a63485b54555a203b8e4fc557e504b3efacb91d1c7c082647848c8c45
|
||||
size 11051
|
||||
oid sha256:871c608b761d8df820122d9d245ac037f71d227e7bc23f7fbe3402662ff05536
|
||||
size 11938
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d174f1f8e77c9cd02506b487e410a7bcbf4d571f048f739d4f10582c72e682d0
|
||||
size 7501
|
||||
oid sha256:9bee4a7a63485b54555a203b8e4fc557e504b3efacb91d1c7c082647848c8c45
|
||||
size 11051
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:000c36468af4793e4956a4a2a2be1867afefa13140219e2dd8b17b1f7670b6de
|
||||
size 6299
|
||||
oid sha256:d174f1f8e77c9cd02506b487e410a7bcbf4d571f048f739d4f10582c72e682d0
|
||||
size 7501
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a6bb04b4dc4dfb43adb8ae45670f20232e8a3ba50c409c50a86feb96dfb00d56
|
||||
size 24486
|
||||
oid sha256:000c36468af4793e4956a4a2a2be1867afefa13140219e2dd8b17b1f7670b6de
|
||||
size 6299
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5694d2ee8d939aa3cc5107b7601ff251481fc8226981208b603507d407dcb12a
|
||||
size 10637
|
||||
oid sha256:a6bb04b4dc4dfb43adb8ae45670f20232e8a3ba50c409c50a86feb96dfb00d56
|
||||
size 24486
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e4b01a055e3e8f64b18d045717ee2d6c9795ef1cf2a923c0fe3c5ac26f6e9a62
|
||||
size 17091
|
||||
oid sha256:5694d2ee8d939aa3cc5107b7601ff251481fc8226981208b603507d407dcb12a
|
||||
size 10637
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e4b01a055e3e8f64b18d045717ee2d6c9795ef1cf2a923c0fe3c5ac26f6e9a62
|
||||
size 17091
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5e3400d75df1c13bde15394e1ab69df7657257bce86b7ac16953142ea7c61389
|
||||
size 41775
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5ba55e0d3271f81d8bcbd6f0a956f50c8e4ed082b23f565357c0c732f2bd7395
|
||||
size 38805
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:659e2f942ff1ba3aee9e63ec329aac2386e755c04324c3865dae892f67f5ade5
|
||||
size 38762
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:398dd93176f5bb6058ccd8a3d4257bec7d14ebd2043d11c6b9f0de0108bf9b68
|
||||
size 42708
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a67e601d851d95f73fe7f6165b91703e87f6c1f71fc6c1d9c614d46ee35623ee
|
||||
size 39515
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:533db6c347770157ef484b53218885a04f12f732775a94da8b915d700d356256
|
||||
size 39399
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d23d7fef77a749e30f90e2222d7c0c5dc191aca7c3b94ebf05ed449dfe7c4b5b
|
||||
size 24276
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5b5727b159ca6d4acc82c72befac497a6179b60696184e53f60fc0ec1cd6799b
|
||||
size 24107
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8ab40b06cc3b9e9b1926085160d64919dad9be61dcf504ee8778357db952fca0
|
||||
size 33062
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5f34cf73456894888268d0ee3d75881a11a3efb4c09534d6885f832e20081f57
|
||||
size 31830
|
||||
Loading…
Add table
Add a link
Reference in a new issue