diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/SecureBackupConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt similarity index 51% rename from appconfig/src/main/kotlin/io/element/android/appconfig/SecureBackupConfig.kt rename to appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt index c4bd418aca..662c332582 100644 --- a/appconfig/src/main/kotlin/io/element/android/appconfig/SecureBackupConfig.kt +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt @@ -7,6 +7,7 @@ package io.element.android.appconfig -object SecureBackupConfig { - const val LEARN_MORE_URL: String = "https://element.io/help#encryption5" +object LearnMoreConfig { + const val SECURE_BACKUP_URL: String = "https://element.io/help#encryption5" + const val IDENTITY_CHANGE_URL: String = "https://element.io/help#encryption18" } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index f0bf8a8970..bf76f208e3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -7,6 +7,7 @@ package io.element.android.features.messages.impl +import android.app.Activity import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -26,19 +27,19 @@ import com.bumble.appyx.core.plugin.plugins import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode +import io.element.android.compound.theme.ElementTheme import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.model.TimelineItem -import io.element.android.libraries.androidutils.system.openUrlInExternalApp +import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.androidutils.system.toast import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.inputs import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.designsystem.utils.OnLifecycleEvent -import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom import io.element.android.libraries.matrix.api.core.EventId @@ -63,8 +64,6 @@ class MessagesNode @AssistedInject constructor( private val timelineItemPresenterFactories: TimelineItemPresenterFactories, private val mediaPlayer: MediaPlayer, private val permalinkParser: PermalinkParser, - @ApplicationContext - private val context: Context, ) : Node(buildContext, plugins = plugins), MessagesNavigator { private val presenter = presenterFactory.create(this) private val callbacks = plugins() @@ -124,7 +123,8 @@ class MessagesNode @AssistedInject constructor( } private fun onLinkClick( - context: Context, + activity: Activity, + darkTheme: Boolean, url: String, eventSink: (TimelineEvents) -> Unit, ) { @@ -135,16 +135,20 @@ class MessagesNode @AssistedInject constructor( callbacks.forEach { it.onUserDataClick(permalink.userId) } } is PermalinkData.RoomLink -> { - handleRoomLinkClick(permalink, eventSink) + handleRoomLinkClick(activity, permalink, eventSink) } is PermalinkData.FallbackLink, is PermalinkData.RoomEmailInviteLink -> { - context.openUrlInExternalApp(url) + activity.openUrlInChromeCustomTab(null, darkTheme, url) } } } - private fun handleRoomLinkClick(roomLink: PermalinkData.RoomLink, eventSink: (TimelineEvents) -> Unit) { + private fun handleRoomLinkClick( + context: Context, + roomLink: PermalinkData.RoomLink, + eventSink: (TimelineEvents) -> Unit, + ) { if (room.matches(roomLink.roomIdOrAlias)) { val eventId = roomLink.eventId if (eventId != null) { @@ -192,7 +196,8 @@ class MessagesNode @AssistedInject constructor( @Composable override fun View(modifier: Modifier) { - val context = LocalContext.current + val activity = LocalContext.current as Activity + val isDark = ElementTheme.isLightTheme.not() CompositionLocalProvider( LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, ) { @@ -210,7 +215,7 @@ class MessagesNode @AssistedInject constructor( onEventClick = this::onEventClick, onPreviewAttachments = this::onPreviewAttachments, onUserDataClick = this::onUserDataClick, - onLinkClick = { onLinkClick(context, it, state.timelineState.eventSink) }, + onLinkClick = { url -> onLinkClick(activity, isDark, url, state.timelineState.eventSink) }, onSendLocationClick = this::onSendLocationClick, onCreatePollClick = this::onCreatePollClick, onJoinCallClick = this::onJoinCallClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 236ea211fe..a42a09fcd6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor +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.pinned.banner.PinnedMessagesBannerState @@ -91,6 +92,7 @@ class MessagesPresenter @AssistedInject constructor( private val voiceMessageComposerPresenter: Presenter, timelinePresenterFactory: TimelinePresenter.Factory, private val timelineProtectionPresenter: Presenter, + private val identityChangeStatePresenter: Presenter, private val actionListPresenterFactory: ActionListPresenter.Factory, private val customReactionPresenter: Presenter, private val reactionSummaryPresenter: Presenter, @@ -125,6 +127,7 @@ class MessagesPresenter @AssistedInject constructor( val voiceMessageComposerState = voiceMessageComposerPresenter.present() val timelineState = timelinePresenter.present() val timelineProtectionState = timelineProtectionPresenter.present() + val identityChangeState = identityChangeStatePresenter.present() val actionListState = actionListPresenter.present() val customReactionState = customReactionPresenter.present() val reactionSummaryState = reactionSummaryPresenter.present() @@ -217,6 +220,7 @@ class MessagesPresenter @AssistedInject constructor( voiceMessageComposerState = voiceMessageComposerState, timelineState = timelineState, timelineProtectionState = timelineProtectionState, + identityChangeState = identityChangeState, actionListState = actionListState, customReactionState = customReactionState, reactionSummaryState = reactionSummaryState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 2e03cbdb9d..2dc43030a4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -9,6 +9,7 @@ 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.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState import io.element.android.features.messages.impl.timeline.TimelineState @@ -34,6 +35,7 @@ data class MessagesState( val voiceMessageComposerState: VoiceMessageComposerState, val timelineState: TimelineState, val timelineProtectionState: TimelineProtectionState, + val identityChangeState: IdentityChangeState, val actionListState: ActionListState, val customReactionState: CustomReactionState, val reactionSummaryState: ReactionSummaryState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 985471c641..c8a8ee6f1f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -10,6 +10,8 @@ package io.element.android.features.messages.impl 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.anIdentityChangeState import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState @@ -106,6 +108,7 @@ fun aMessagesState( focusedEventIndex = 2, ), timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(), + identityChangeState: IdentityChangeState = anIdentityChangeState(), readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(), actionListState: ActionListState = anActionListState(), customReactionState: CustomReactionState = aCustomReactionState(), @@ -125,6 +128,7 @@ fun aMessagesState( composerState = composerState, voiceMessageComposerState = voiceMessageComposerState, timelineProtectionState = timelineProtectionState, + identityChangeState = identityChangeState, timelineState = timelineState, readReceiptBottomSheetState = readReceiptBottomSheetState, actionListState = actionListState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 36bf0bc5fb..e56d37005b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -57,6 +57,7 @@ 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 import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents @@ -103,6 +104,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import timber.log.Timber @@ -415,6 +417,7 @@ private fun MessagesViewContent( MessagesViewComposerBottomSheetContents( subcomposing = subcomposing, state = state, + onLinkClick = onLinkClick, ) }, sheetContentKey = sheetResizeContentKey.intValue, @@ -428,6 +431,7 @@ private fun MessagesViewContent( private fun MessagesViewComposerBottomSheetContents( subcomposing: Boolean, state: MessagesState, + onLinkClick: (String) -> Unit, ) { if (state.userEventPermissions.canSendMessage) { Column(modifier = Modifier.fillMaxWidth()) { @@ -448,6 +452,14 @@ private fun MessagesViewComposerBottomSheetContents( state.composerState.eventSink(MessageComposerEvents.InsertSuggestion(it)) } ) + // Do not show the identity change if user is composing a Rich message or is seeing suggestion(s). + if (state.composerState.suggestions.isEmpty() && + state.composerState.textEditorState is TextEditorState.Markdown) { + IdentityChangeStateView( + state = state.identityChangeState, + onLinkClick = onLinkClick, + ) + } MessageComposerView( state = state.composerState, voiceMessageState = state.voiceMessageComposerState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeEvent.kt new file mode 100644 index 0000000000..df58c0346e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeEvent.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import io.element.android.libraries.matrix.api.core.UserId + +sealed interface IdentityChangeEvent { + data class Submit(val userId: UserId) : IdentityChangeEvent +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeState.kt new file mode 100644 index 0000000000..62491235ec --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeState.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import kotlinx.collections.immutable.ImmutableList + +data class IdentityChangeState( + val roomMemberIdentityStateChanges: ImmutableList, + val eventSink: (IdentityChangeEvent) -> Unit, +) + +data class RoomMemberIdentityStateChange( + val identityRoomMember: IdentityRoomMember, + val identityState: IdentityState, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt new file mode 100644 index 0000000000..9b338b4833 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProduceStateScope +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.rememberCoroutineScope +import io.element.android.libraries.architecture.Presenter +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.EncryptionService +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.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +class IdentityChangeStatePresenter @Inject constructor( + private val room: MatrixRoom, + private val encryptionService: EncryptionService, +) : Presenter { + @Composable + override fun present(): IdentityChangeState { + val coroutineScope = rememberCoroutineScope() + val roomMemberIdentityStateChange by produceState(persistentListOf()) { + observeRoomMemberIdentityStateChange() + } + + fun handleEvent(event: IdentityChangeEvent) { + when (event) { + is IdentityChangeEvent.Submit -> coroutineScope.pinUserIdentity(event.userId) + } + } + + return IdentityChangeState( + roomMemberIdentityStateChanges = roomMemberIdentityStateChange, + eventSink = ::handleEvent, + ) + } + + private fun ProduceStateScope>.observeRoomMemberIdentityStateChange() { + combine(room.identityStateChangesFlow, room.membersStateFlow) { identityStateChanges, membersState -> + identityStateChanges.map { identityStateChange -> + val member = membersState.roomMembers() + ?.firstOrNull { roomMember -> roomMember.userId == identityStateChange.userId } + ?.toIdentityRoomMember() + ?: createDefaultRoomMemberForIdentityChange(identityStateChange.userId) + RoomMemberIdentityStateChange( + identityRoomMember = member, + identityState = identityStateChange.identityState, + ) + } + } + .distinctUntilChanged() + .onEach { roomMemberIdentityStateChanges -> + value = roomMemberIdentityStateChanges.toPersistentList() + } + .launchIn(this) + } + + private fun CoroutineScope.pinUserIdentity(userId: UserId) = launch { + encryptionService.pinUserIdentity(userId) + .onFailure { + Timber.e(it, "Failed to pin identity for user $userId") + } + } +} + +private fun RoomMember.toIdentityRoomMember() = IdentityRoomMember( + userId = userId, + disambiguatedDisplayName = disambiguatedDisplayName, + avatarData = getAvatarData(AvatarSize.ComposerAlert), +) + +private fun createDefaultRoomMemberForIdentityChange(userId: UserId) = IdentityRoomMember( + userId = userId, + disambiguatedDisplayName = userId.value, + avatarData = AvatarData( + id = userId.value, + name = null, + url = null, + size = AvatarSize.ComposerAlert, + ), +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt new file mode 100644 index 0000000000..fa70bebe6a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +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 kotlinx.collections.immutable.toImmutableList + +class IdentityChangeStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anIdentityChangeState(), + anIdentityChangeState( + roomMemberIdentityStateChanges = listOf( + RoomMemberIdentityStateChange( + identityRoomMember = anIdentityRoomMember(disambiguatedDisplayName = "Alice"), + identityState = IdentityState.PinViolation, + ), + ), + ), + ) +} + +internal fun anIdentityChangeState( + roomMemberIdentityStateChanges: List = emptyList(), +) = IdentityChangeState( + roomMemberIdentityStateChanges = roomMemberIdentityStateChanges.toImmutableList(), + eventSink = {}, +) + +internal fun anIdentityRoomMember( + userId: UserId = UserId("@alice:example.com"), + disambiguatedDisplayName: String = userId.value, + avatarData: AvatarData = AvatarData( + id = userId.value, + name = null, + url = null, + size = AvatarSize.ComposerAlert, + ), +) = IdentityRoomMember( + userId = userId, + disambiguatedDisplayName = disambiguatedDisplayName, + avatarData = avatarData, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt new file mode 100644 index 0000000000..6ff167a8a7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.appconfig.LearnMoreConfig +import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule +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.ui.strings.CommonStrings + +@Composable +fun IdentityChangeStateView( + state: IdentityChangeState, + onLinkClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + // Pick the first identity change to PinViolation + val pinViolationIdentityChange = state.roomMemberIdentityStateChanges.firstOrNull { + // For now only render PinViolation + it.identityState == IdentityState.PinViolation + } + if (pinViolationIdentityChange != null) { + ComposerAlertMolecule( + modifier = modifier, + avatar = pinViolationIdentityChange.identityRoomMember.avatarData, + content = buildAnnotatedString { + val learnMoreStr = stringResource(CommonStrings.action_learn_more) + val fullText = stringResource( + id = CommonStrings.crypto_identity_change_pin_violation, + pinViolationIdentityChange.identityRoomMember.disambiguatedDisplayName, + learnMoreStr, + ) + val learnMoreStartIndex = fullText.indexOf(learnMoreStr) + append(fullText) + addStyle( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + fontWeight = FontWeight.Bold, + ), + start = learnMoreStartIndex, + end = learnMoreStartIndex + learnMoreStr.length, + ) + addLink( + url = LinkAnnotation.Url( + url = LearnMoreConfig.IDENTITY_CHANGE_URL, + linkInteractionListener = { + onLinkClick(LearnMoreConfig.IDENTITY_CHANGE_URL) + } + ), + start = learnMoreStartIndex, + end = learnMoreStartIndex + learnMoreStr.length, + ) + }, + onSubmitClick = { state.eventSink(IdentityChangeEvent.Submit(pinViolationIdentityChange.identityRoomMember.userId)) }, + isCritical = pinViolationIdentityChange.identityState == IdentityState.VerificationViolation, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun IdentityChangeStateViewPreview( + @PreviewParameter(IdentityChangeStateProvider::class) state: IdentityChangeState, +) = ElementPreview { + IdentityChangeStateView( + state = state, + onLinkClick = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityRoomMember.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityRoomMember.kt new file mode 100644 index 0000000000..f5ad9295b3 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityRoomMember.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE 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 disambiguatedDisplayName: String, + val avatarData: AvatarData, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt new file mode 100644 index 0000000000..1b57c52d23 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.messages.impl.MessagesView +import io.element.android.features.messages.impl.aMessagesState +import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState +import io.element.android.libraries.textcomposer.model.TextEditorState + +@PreviewsDayNight +@Composable +internal fun MessagesViewWithIdentityChangePreview( + @PreviewParameter(IdentityChangeStateProvider::class) identityChangeState: IdentityChangeState +) = ElementPreview { + MessagesView( + state = aMessagesState( + composerState = aMessageComposerState( + textEditorState = TextEditorState.Markdown( + state = MarkdownTextEditorState( + initialText = "", + initialFocus = false, + ) + ) + ), + identityChangeState = identityChangeState, + ), + onBackClick = {}, + onRoomDetailsClick = {}, + onEventClick = { false }, + onUserDataClick = {}, + onLinkClick = {}, + onPreviewAttachments = {}, + onSendLocationClick = {}, + onCreatePollClick = {}, + onJoinCallClick = {}, + onViewAllPinnedMessagesClick = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt index 31c1d6a758..d987e97809 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt @@ -10,6 +10,8 @@ package io.element.android.features.messages.impl.di import com.squareup.anvil.annotations.ContributesTo import dagger.Binds import dagger.Module +import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState +import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter @@ -60,4 +62,7 @@ interface MessagesModule { @Binds fun bindReadReceiptBottomSheetPresenter(presenter: ReadReceiptBottomSheetPresenter): Presenter + + @Binds + fun bindIdentityChangeStatePresenter(presenter: IdentityChangeStatePresenter): Presenter } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index 1663ea292d..0a2a189e56 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -33,10 +33,12 @@ class TimelineItemEventContentProvider : PreviewParameterProvider { private fun buildSpanned(text: String) = buildSpannedString { inSpans(StyleSpan(Typeface.BOLD)) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt index 8581e1f215..cea8d26b14 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt @@ -9,21 +9,22 @@ package io.element.android.features.messages.impl.typing import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.ProduceStateScope 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.setValue import io.element.android.libraries.architecture.Presenter 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.RoomMember -import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn @@ -36,64 +37,56 @@ class TypingNotificationPresenter @Inject constructor( ) : Presenter { @Composable override fun present(): TypingNotificationState { - val typingMembersState = remember { mutableStateOf(emptyList()) } val renderTypingNotifications by sessionPreferencesStore.isRenderTypingNotificationsEnabled().collectAsState(initial = true) - - LaunchedEffect(renderTypingNotifications) { + val typingMembersState by produceState(initialValue = persistentListOf(), key1 = renderTypingNotifications) { if (renderTypingNotifications) { - observeRoomTypingMembers(typingMembersState) + observeRoomTypingMembers() } else { - typingMembersState.value = emptyList() + value = persistentListOf() } } // This will keep the space reserved for the typing notifications after the first one is displayed var reserveSpace by remember { mutableStateOf(false) } - LaunchedEffect(renderTypingNotifications, typingMembersState.value) { - if (renderTypingNotifications && typingMembersState.value.isNotEmpty()) { + LaunchedEffect(renderTypingNotifications, typingMembersState) { + if (renderTypingNotifications && typingMembersState.isNotEmpty()) { reserveSpace = true } } return TypingNotificationState( renderTypingNotifications = renderTypingNotifications, - typingMembers = typingMembersState.value.toImmutableList(), + typingMembers = typingMembersState, reserveSpace = reserveSpace, ) } - private fun CoroutineScope.observeRoomTypingMembers(typingMembersState: MutableState>) { + private fun ProduceStateScope>.observeRoomTypingMembers() { combine(room.roomTypingMembersFlow, room.membersStateFlow) { typingMembers, membersState -> typingMembers .map { userId -> membersState.roomMembers() ?.firstOrNull { roomMember -> roomMember.userId == userId } + ?.toTypingRoomMember() ?: createDefaultRoomMemberForTyping(userId) } } .distinctUntilChanged() .onEach { members -> - typingMembersState.value = members + value = members.toImmutableList() } .launchIn(this) } } -/** - * Create a default [RoomMember] for typing events. - * In this case, only the userId will be used for rendering, other fields are not used, but keep them - * as close as possible to the actual data. - */ -private fun createDefaultRoomMemberForTyping(userId: UserId): RoomMember { - return RoomMember( - userId = userId, - displayName = null, - avatarUrl = null, - membership = RoomMembershipState.JOIN, - isNameAmbiguous = false, - powerLevel = 0, - normalizedPowerLevel = 0, - isIgnored = false, - role = RoomMember.Role.USER, +private fun RoomMember.toTypingRoomMember(): TypingRoomMember { + return TypingRoomMember( + disambiguatedDisplayName = disambiguatedDisplayName, + ) +} + +private fun createDefaultRoomMemberForTyping(userId: UserId): TypingRoomMember { + return TypingRoomMember( + disambiguatedDisplayName = userId.value, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationState.kt index e4c239449c..c94cfd4cb9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationState.kt @@ -7,7 +7,6 @@ package io.element.android.features.messages.impl.typing -import io.element.android.libraries.matrix.api.room.RoomMember import kotlinx.collections.immutable.ImmutableList /** @@ -17,7 +16,7 @@ data class TypingNotificationState( /** Whether to render the typing notifications based on the user's preferences. */ val renderTypingNotifications: Boolean, /** The room members currently typing. */ - val typingMembers: ImmutableList, + val typingMembers: ImmutableList, /** Whether to reserve space for the typing notifications at the bottom of the timeline. */ val reserveSpace: Boolean, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationStateForMessagesProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationStateForMessagesProvider.kt deleted file mode 100644 index b1757b5545..0000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationStateForMessagesProvider.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2024 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only - * Please see LICENSE in the repository root for full details. - */ - -package io.element.android.features.messages.impl.typing - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider - -class TypingNotificationStateForMessagesProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - aTypingNotificationState( - typingMembers = listOf( - aTypingRoomMember(displayName = "Alice"), - aTypingRoomMember(displayName = "Bob"), - ), - ), - aTypingNotificationState( - typingMembers = listOf(aTypingRoomMember()), - reserveSpace = true - ), - aTypingNotificationState(reserveSpace = true), - ) -} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationStateProvider.kt index 5baf868417..3722185d00 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationStateProvider.kt @@ -8,9 +8,6 @@ package io.element.android.features.messages.impl.typing import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.matrix.api.room.RoomMember -import io.element.android.libraries.matrix.api.room.RoomMembershipState import kotlinx.collections.immutable.toImmutableList class TypingNotificationStateProvider : PreviewParameterProvider { @@ -24,39 +21,39 @@ class TypingNotificationStateProvider : PreviewParameterProvider = emptyList(), + typingMembers: List = emptyList(), reserveSpace: Boolean = false, ) = TypingNotificationState( renderTypingNotifications = true, @@ -76,19 +73,7 @@ internal fun aTypingNotificationState( ) internal fun aTypingRoomMember( - userId: UserId = UserId("@alice:example.com"), - displayName: String? = null, - isNameAmbiguous: Boolean = false, -): RoomMember { - return RoomMember( - userId = userId, - displayName = displayName, - avatarUrl = null, - membership = RoomMembershipState.JOIN, - isNameAmbiguous = isNameAmbiguous, - powerLevel = 0, - normalizedPowerLevel = 0, - isIgnored = false, - role = RoomMember.Role.USER, - ) -} + disambiguatedDisplayName: String = "@alice:example.com", +) = TypingRoomMember( + disambiguatedDisplayName = disambiguatedDisplayName, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationView.kt index 01dcb6e141..1142341984 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationView.kt @@ -41,7 +41,6 @@ import io.element.android.features.messages.impl.R import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.matrix.api.room.RoomMember import kotlinx.collections.immutable.ImmutableList @Suppress("MultipleEmitters") // False positive @@ -53,7 +52,8 @@ fun TypingNotificationView( val displayNotifications = state.typingMembers.isNotEmpty() && state.renderTypingNotifications @Suppress("ModifierNaming") - @Composable fun TypingText(text: AnnotatedString, textModifier: Modifier = Modifier) { + @Composable + fun TypingText(text: AnnotatedString, textModifier: Modifier = Modifier) { Text( modifier = textModifier, text = text, @@ -66,7 +66,9 @@ fun TypingNotificationView( // Display the typing notification space when either a typing notification needs to be displayed or a previous one already was AnimatedVisibility( - modifier = modifier.fillMaxWidth().padding(vertical = 2.dp), + modifier = modifier + .fillMaxWidth() + .padding(vertical = 2.dp), visible = displayNotifications || state.reserveSpace, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), @@ -95,7 +97,7 @@ fun TypingNotificationView( } @Composable -private fun computeTypingNotificationText(typingMembers: ImmutableList): AnnotatedString { +private fun computeTypingNotificationText(typingMembers: ImmutableList): AnnotatedString { // Remember the last value to avoid empty typing messages while animating var result by remember { mutableStateOf(AnnotatedString("")) } if (typingMembers.isNotEmpty()) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingRoomMember.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingRoomMember.kt new file mode 100644 index 0000000000..edd658763f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingRoomMember.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.typing + +data class TypingRoomMember( + val disambiguatedDisplayName: String, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/Emoji.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/Emoji.kt index 8780bcd145..cf17bca000 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/Emoji.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/Emoji.kt @@ -7,17 +7,25 @@ package io.element.android.features.messages.impl.utils +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalInspectionMode import com.sigpwned.emoji4j.core.Grapheme.Type.EMOJI import com.sigpwned.emoji4j.core.Grapheme.Type.PICTOGRAPHIC import com.sigpwned.emoji4j.core.GraphemeMatchResult import com.sigpwned.emoji4j.core.GraphemeMatcher +import io.element.android.features.messages.impl.timeline.model.event.AN_EMOJI_ONLY_TEXT /** * Returns true if the string consists exclusively of "emoji or pictographic graphemes". */ +@Composable fun String.containsOnlyEmojis(): Boolean { + if (LocalInspectionMode.current) return this == AN_EMOJI_ONLY_TEXT if (isEmpty()) return false + return containsOnlyEmojisInternal() +} +internal fun String.containsOnlyEmojisInternal(): Boolean { val matcher = GraphemeMatcher(this) var m: GraphemeMatchResult? = null var contiguous = true diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 284f141d41..078d4672bb 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -16,6 +16,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState import io.element.android.features.messages.impl.fixtures.aMessageEvent import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerState @@ -1026,6 +1027,7 @@ class MessagesPresenterTest { customReactionPresenter = { aCustomReactionState() }, reactionSummaryPresenter = { aReactionSummaryState() }, readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() }, + identityChangeStatePresenter = { anIdentityChangeState() }, pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() }, networkMonitor = FakeNetworkMonitor(), snackbarDispatcher = SnackbarDispatcher(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenterTest.kt new file mode 100644 index 0000000000..5235361870 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenterTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import com.google.common.truth.Truth.assertThat +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.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.api.room.MatrixRoomMembersState +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.encryption.FakeEncryptionService +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class IdentityChangeStatePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createIdentityChangeStatePresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.roomMemberIdentityStateChanges).isEmpty() + } + } + + @Test + fun `present - when the room emits identity change, the presenter emits new state`() = runTest { + val room = FakeMatrixRoom() + val presenter = createIdentityChangeStatePresenter(room) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.roomMemberIdentityStateChanges).isEmpty() + room.emitIdentityStateChanges( + listOf( + IdentityStateChange( + userId = A_USER_ID_2, + identityState = IdentityState.PinViolation, + ), + ) + ) + val finalItem = awaitItem() + assertThat(finalItem.roomMemberIdentityStateChanges).hasSize(1) + val value = finalItem.roomMemberIdentityStateChanges.first() + assertThat(value.identityRoomMember.userId).isEqualTo(A_USER_ID_2) + assertThat(value.identityState).isEqualTo(IdentityState.PinViolation) + } + } + + @Test + fun `present - when the room emits identity change, the presenter emits new state with member details`() = + runTest { + val room = FakeMatrixRoom().apply { + givenRoomMembersState( + MatrixRoomMembersState.Ready( + listOf( + aRoomMember( + A_USER_ID_2, + displayName = "Alice", + ), + ).toImmutableList() + ) + ) + } + val presenter = createIdentityChangeStatePresenter(room) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.roomMemberIdentityStateChanges).isEmpty() + room.emitIdentityStateChanges( + listOf( + IdentityStateChange( + userId = A_USER_ID_2, + identityState = IdentityState.PinViolation, + ), + ) + ) + val finalItem = awaitItem() + assertThat(finalItem.roomMemberIdentityStateChanges).hasSize(1) + val value = finalItem.roomMemberIdentityStateChanges.first() + assertThat(value.identityRoomMember.userId).isEqualTo(A_USER_ID_2) + assertThat(value.identityRoomMember.disambiguatedDisplayName).isEqualTo("Alice") + assertThat(value.identityRoomMember.avatarData.size).isEqualTo(AvatarSize.ComposerAlert) + assertThat(value.identityState).isEqualTo(IdentityState.PinViolation) + } + } + + @Test + fun `present - when the user pin the identity, the presenter invokes the encryption service api`() = + runTest { + val lambda = lambdaRecorder> { Result.success(Unit) } + val encryptionService = FakeEncryptionService( + pinUserIdentityResult = lambda, + ) + val presenter = createIdentityChangeStatePresenter(encryptionService = encryptionService) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(IdentityChangeEvent.Submit(A_USER_ID)) + lambda.assertions().isCalledOnce().with(value(A_USER_ID)) + } + } + + private fun createIdentityChangeStatePresenter( + room: MatrixRoom = FakeMatrixRoom(), + encryptionService: EncryptionService = FakeEncryptionService(), + ): IdentityChangeStatePresenter { + return IdentityChangeStatePresenter( + room = room, + encryptionService = encryptionService, + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt index 61c37dc449..ab26da66bb 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt @@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID_3 import io.element.android.libraries.matrix.test.A_USER_ID_4 import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.tests.testutils.WarmUpRule @@ -49,7 +50,6 @@ class TypingNotificationPresenterTest { @Test fun `present - typing notification disabled`() = runTest { - val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2) val room = FakeMatrixRoom() val sessionPreferencesStore = InMemorySessionPreferencesStore( isRenderTypingNotificationsEnabled = false @@ -73,7 +73,11 @@ class TypingNotificationPresenterTest { val oneMemberTypingState = awaitItem() assertThat(oneMemberTypingState.renderTypingNotifications).isTrue() assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1) - assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember) + assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo( + TypingRoomMember( + disambiguatedDisplayName = A_USER_ID_2.value, + ) + ) // Preferences changes again sessionPreferencesStore.setRenderTypingNotifications(false) skipItems(2) @@ -85,7 +89,6 @@ class TypingNotificationPresenterTest { @Test fun `present - state is updated when a member is typing, member is not known`() = runTest { - val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2) val room = FakeMatrixRoom() val presenter = createPresenter(matrixRoom = room) moleculeFlow(RecompositionMode.Immediate) { @@ -96,7 +99,11 @@ class TypingNotificationPresenterTest { room.givenRoomTypingMembers(listOf(A_USER_ID_2)) val oneMemberTypingState = awaitItem() assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1) - assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember) + assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo( + TypingRoomMember( + disambiguatedDisplayName = A_USER_ID_2.value, + ) + ) // User stops typing room.givenRoomTypingMembers(emptyList()) skipItems(1) @@ -129,7 +136,11 @@ class TypingNotificationPresenterTest { room.givenRoomTypingMembers(listOf(A_USER_ID_2)) val oneMemberTypingState = awaitItem() assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1) - assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aKnownRoomMember) + assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo( + TypingRoomMember( + disambiguatedDisplayName = "Alice Doe (@bob:server.org)", + ) + ) // User stops typing room.givenRoomTypingMembers(emptyList()) skipItems(1) @@ -140,7 +151,6 @@ class TypingNotificationPresenterTest { @Test fun `present - state is updated when a member is typing, member is not known, then known`() = runTest { - val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2) val aKnownRoomMember = createKnownRoomMember(A_USER_ID_2) val room = FakeMatrixRoom() val presenter = createPresenter(matrixRoom = room) @@ -152,7 +162,11 @@ class TypingNotificationPresenterTest { room.givenRoomTypingMembers(listOf(A_USER_ID_2)) val oneMemberTypingState = awaitItem() assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1) - assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember) + assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo( + TypingRoomMember( + disambiguatedDisplayName = A_USER_ID_2.value, + ) + ) // User is getting known room.givenRoomMembersState( MatrixRoomMembersState.Ready( @@ -161,7 +175,11 @@ class TypingNotificationPresenterTest { ) skipItems(1) val finalState = awaitItem() - assertThat(finalState.typingMembers.first()).isEqualTo(aKnownRoomMember) + assertThat(finalState.typingMembers.first()).isEqualTo( + TypingRoomMember( + disambiguatedDisplayName = "Alice Doe (@bob:server.org)", + ) + ) } } @@ -202,17 +220,9 @@ class TypingNotificationPresenterTest { sessionPreferencesStore = sessionPreferencesStore, ) - private fun createDefaultRoomMember( - userId: UserId, - ) = aTypingRoomMember( - userId = userId, - displayName = null, - isNameAmbiguous = false, - ) - private fun createKnownRoomMember( userId: UserId, - ) = aTypingRoomMember( + ) = aRoomMember( userId = userId, displayName = "Alice Doe", isNameAmbiguous = true, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/EmojiTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/EmojiTest.kt index 88e9fb0c5b..17dbeb5ed2 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/EmojiTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/EmojiTest.kt @@ -8,29 +8,30 @@ package io.element.android.features.messages.impl.utils import org.junit.Assert +import org.junit.Assert.assertTrue import org.junit.Test class EmojiTest { @Test fun validEmojis() { // Simple single/multiple single-codepoint emojis per string - Assert.assertTrue("πŸ‘".containsOnlyEmojis()) - Assert.assertTrue("πŸ˜€".containsOnlyEmojis()) - Assert.assertTrue("πŸ™‚πŸ™".containsOnlyEmojis()) - Assert.assertTrue("πŸ‘β€οΈπŸ".containsOnlyEmojis()) // πŸ‘ is a pictographic - Assert.assertTrue("πŸ‘¨β€πŸ‘©β€πŸ‘¦1οΈβƒ£πŸš€πŸ‘³πŸΎβ€β™‚οΈπŸͺ©".containsOnlyEmojis()) - Assert.assertTrue("🌍🌎🌏".containsOnlyEmojis()) + assertTrue("πŸ‘".containsOnlyEmojisInternal()) + assertTrue("πŸ˜€".containsOnlyEmojisInternal()) + assertTrue("πŸ™‚πŸ™".containsOnlyEmojisInternal()) + assertTrue("πŸ‘β€οΈπŸ".containsOnlyEmojisInternal()) // πŸ‘ is a pictographic + assertTrue("πŸ‘¨β€πŸ‘©β€πŸ‘¦1οΈβƒ£πŸš€πŸ‘³πŸΎβ€β™‚οΈπŸͺ©".containsOnlyEmojisInternal()) + assertTrue("🌍🌎🌏".containsOnlyEmojisInternal()) // Awkward multi-codepoint graphemes - Assert.assertTrue("πŸ§‘β€πŸ§‘β€πŸ§’β€πŸ§’".containsOnlyEmojis()) - Assert.assertTrue("πŸ΄β€β˜ ".containsOnlyEmojis()) - Assert.assertTrue("πŸ‘©πŸΏβ€πŸ”§".containsOnlyEmojis()) + assertTrue("πŸ§‘β€πŸ§‘β€πŸ§’β€πŸ§’".containsOnlyEmojisInternal()) + assertTrue("πŸ΄β€β˜ ".containsOnlyEmojisInternal()) + assertTrue("πŸ‘©πŸΏβ€πŸ”§".containsOnlyEmojisInternal()) - Assert.assertFalse("".containsOnlyEmojis()) - Assert.assertFalse(" ".containsOnlyEmojis()) - Assert.assertFalse("πŸ™‚ πŸ™".containsOnlyEmojis()) - Assert.assertFalse(" πŸ™‚ πŸ™ ".containsOnlyEmojis()) - Assert.assertFalse("Hello".containsOnlyEmojis()) - Assert.assertFalse("Hello πŸ‘‹".containsOnlyEmojis()) + Assert.assertFalse("".containsOnlyEmojisInternal()) + Assert.assertFalse(" ".containsOnlyEmojisInternal()) + Assert.assertFalse("πŸ™‚ πŸ™".containsOnlyEmojisInternal()) + Assert.assertFalse(" πŸ™‚ πŸ™ ".containsOnlyEmojisInternal()) + Assert.assertFalse("Hello".containsOnlyEmojisInternal()) + Assert.assertFalse("Hello πŸ‘‹".containsOnlyEmojisInternal()) } } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt index c39d6a8d36..113c569d39 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt @@ -18,7 +18,7 @@ import com.bumble.appyx.core.plugin.plugins import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode -import io.element.android.appconfig.SecureBackupConfig +import io.element.android.appconfig.LearnMoreConfig import io.element.android.libraries.di.SessionScope @ContributesNode(SessionScope::class) @@ -59,7 +59,7 @@ class SecureBackupRootNode @AssistedInject constructor( } private fun onLearnMoreClick(uriHandler: UriHandler) { - uriHandler.openUri(SecureBackupConfig.LEARN_MORE_URL) + uriHandler.openUri(LearnMoreConfig.SECURE_BACKUP_URL) } @Composable diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt index d3d5b2db17..7c282b13d8 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt @@ -13,6 +13,7 @@ import android.net.Uri import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsSession +import io.element.android.libraries.androidutils.system.openUrlInExternalApp /** * Open url in custom tab or, if not available, in the default browser. @@ -53,6 +54,6 @@ fun Activity.openUrlInChromeCustomTab( } .launchUrl(this, Uri.parse(url)) } catch (activityNotFoundException: ActivityNotFoundException) { - // TODO context.toast(R.string.error_no_external_application_found) + openUrlInExternalApp(url) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMolecule.kt new file mode 100644 index 0000000000..e94eb0ee60 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMolecule.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.molecules + +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +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.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.anAvatarData +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.toAnnotatedString +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ButtonSize +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.BooleanProvider +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun ComposerAlertMolecule( + avatar: AvatarData, + content: AnnotatedString, + onSubmitClick: () -> Unit, + modifier: Modifier = Modifier, + isCritical: Boolean = false, + submitText: String = stringResource(CommonStrings.action_ok), +) { + Column( + modifier.fillMaxWidth() + ) { + val lineColor = if (isCritical) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.borderInfoSubtle + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(lineColor) + ) + val startColor = if (isCritical) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgInfoSubtle + val brush = Brush.verticalGradient( + listOf(startColor, ElementTheme.materialColors.background), + ) + Box( + modifier = Modifier + .background(brush) + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Avatar( + avatarData = avatar, + ) + Text( + text = content, + modifier = Modifier.weight(1f), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Start, + ) + } + Button( + text = submitText, + size = ButtonSize.Medium, + modifier = Modifier.fillMaxWidth(), + onClick = onSubmitClick, + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun ComposerAlertMoleculePreview(@PreviewParameter(BooleanProvider::class) isCritical: Boolean) = ElementPreview { + ComposerAlertMolecule( + avatar = anAvatarData(size = AvatarSize.ComposerAlert), + content = "Alice’s verified identity has changed. Learn more".toAnnotatedString(), + isCritical = isCritical, + onSubmitClick = {}, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index c8f572a66a..49a3e93e87 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -33,6 +33,8 @@ enum class AvatarSize(val dp: Dp) { TimelineSender(32.dp), TimelineReadReceipt(16.dp), + ComposerAlert(32.dp), + ReadReceiptList(32.dp), MessageActionSender(32.dp), diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt index 86ddef753a..0bfce8a8d2 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt @@ -7,6 +7,7 @@ package io.element.android.libraries.matrix.api.encryption +import io.element.android.libraries.matrix.api.core.UserId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -58,6 +59,11 @@ interface EncryptionService { * Starts the identity reset process. This will return a handle that can be used to reset the identity. */ suspend fun startIdentityReset(): Result + + /** + * Remember this identity, ensuring it does not result in a pin violation. + */ + suspend fun pinUserIdentity(userId: UserId): Result } /** diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/identity/IdentityState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/identity/IdentityState.kt new file mode 100644 index 0000000000..bbcb2a0375 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/identity/IdentityState.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.encryption.identity + +enum class IdentityState { + /** The user is verified with us. */ + Verified, + + /** + * Either this is the first identity we have seen for this user, or the + * user has acknowledged a change of identity explicitly e.g. by + * clicking OK on a notification. + */ + Pinned, + + /** + * The user's identity has changed since it was pinned. The user should be + * notified about this and given the opportunity to acknowledge the + * change, which will make the new identity pinned. + */ + PinViolation, + + /** + * The user's identity has changed, and before that it was verified. This + * is a serious problem. The user can either verify again to make this + * identity verified, or withdraw verification to make it pinned. + */ + VerificationViolation, +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/identity/IdentityStateChange.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/identity/IdentityStateChange.kt new file mode 100644 index 0000000000..6eef5ff5e6 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/identity/IdentityStateChange.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.encryption.identity + +import io.element.android.libraries.matrix.api.core.UserId + +data class IdentityStateChange( + val userId: UserId, + val identityState: IdentityState, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 98ebc531a5..b8d6d66043 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo @@ -52,6 +53,7 @@ interface MatrixRoom : Closeable { val roomInfoFlow: Flow val roomTypingMembersFlow: Flow> + val identityStateChangesFlow: Flow> /** * A one-to-one is a room with exactly 2 members. diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt index f4e4af7b4f..c84ab859b5 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt @@ -11,6 +11,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress @@ -202,4 +203,9 @@ internal class RustEncryptionService( RustIdentityResetHandleFactory.create(sessionId, handle) } } + + override suspend fun pinUserIdentity(userId: UserId): Result = runCatching { + val userIdentity = service.getUserIdentity(userId.value) ?: error("User identity not found") + userIdentity.pin() + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/IdentityState.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/IdentityState.kt new file mode 100644 index 0000000000..24b8bbfadd --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/IdentityState.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.mapper + +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import uniffi.matrix_sdk_crypto.IdentityState as RustIdentityState + +fun RustIdentityState.map(): IdentityState = when (this) { + RustIdentityState.VERIFIED -> IdentityState.Verified + RustIdentityState.PINNED -> IdentityState.Pinned + RustIdentityState.PIN_VIOLATION -> IdentityState.PinViolation + RustIdentityState.VERIFICATION_VIOLATION -> IdentityState.VerificationViolation +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index c3521ecf99..7bc95471c3 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -19,6 +19,7 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo @@ -43,6 +44,7 @@ import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings +import io.element.android.libraries.matrix.impl.mapper.map import io.element.android.libraries.matrix.impl.room.draft.into import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper @@ -69,6 +71,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener import org.matrix.rustcomponents.sdk.RoomInfo import org.matrix.rustcomponents.sdk.RoomInfoListener import org.matrix.rustcomponents.sdk.RoomListItem @@ -82,6 +85,7 @@ import timber.log.Timber import uniffi.matrix_sdk.RoomPowerLevelChanges import java.io.File import kotlin.coroutines.cancellation.CancellationException +import org.matrix.rustcomponents.sdk.IdentityStatusChange as RustIdentityStateChange import org.matrix.rustcomponents.sdk.Room as InnerRoom import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline @@ -130,6 +134,23 @@ class RustMatrixRoom( }) } + override val identityStateChangesFlow: Flow> = mxCallbackFlow { + val initial = emptyList() + channel.trySend(initial) + innerRoom.subscribeToIdentityStatusChanges(object : IdentityStatusChangeListener { + override fun call(identityStatusChange: List) { + channel.trySend( + identityStatusChange.map { + IdentityStateChange( + userId = UserId(it.userId), + identityState = it.changedTo.map(), + ) + } + ) + } + }) + } + // Create a dispatcher for all room methods... private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt index 935beaf067..6778eb5838 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt @@ -7,6 +7,7 @@ package io.element.android.libraries.matrix.test.encryption +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress @@ -21,6 +22,7 @@ import kotlinx.coroutines.flow.flowOf class FakeEncryptionService( var startIdentityResetLambda: () -> Result = { lambdaError() }, + private val pinUserIdentityResult: (UserId) -> Result = { lambdaError() }, ) : EncryptionService { private var disableRecoveryFailure: Exception? = null override val backupStateStateFlow: MutableStateFlow = MutableStateFlow(BackupState.UNKNOWN) @@ -117,6 +119,10 @@ class FakeEncryptionService( return startIdentityResetLambda() } + override suspend fun pinUserIdentity(userId: UserId): Result { + return pinUserIdentityResult(userId) + } + companion object { const val FAKE_RECOVERY_KEY = "fake" } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 1d78e87369..81f276cc84 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo @@ -137,7 +138,7 @@ class FakeMatrixRoom( private val subscribeToSyncLambda: () -> Unit = { lambdaError() }, private val ignoreDeviceTrustAndResendResult: (Map>, TransactionId) -> Result = { _, _ -> lambdaError() }, private val withdrawVerificationAndResendResult: (List, TransactionId) -> Result = { _, _ -> lambdaError() }, - ) : MatrixRoom { +) : MatrixRoom { private val _roomInfoFlow: MutableSharedFlow = MutableSharedFlow(replay = 1) override val roomInfoFlow: Flow = _roomInfoFlow @@ -152,6 +153,13 @@ class FakeMatrixRoom( _roomTypingMembersFlow.tryEmit(typingMembers) } + private val _identityStateChangesFlow: MutableSharedFlow> = MutableSharedFlow(replay = 1) + override val identityStateChangesFlow: Flow> = _identityStateChangesFlow + + fun emitIdentityStateChanges(identityStateChanges: List) { + _identityStateChangesFlow.tryEmit(identityStateChanges) + } + override val membersStateFlow: MutableStateFlow = MutableStateFlow(MatrixRoomMembersState.Unknown) override val roomNotificationSettingsStateFlow: MutableStateFlow = diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt index ac3474e7f9..9db116ebc5 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt @@ -75,6 +75,7 @@ class KonsistPreviewTest { "MessageComposerViewVoicePreview", "MessagesReactionButtonAddPreview", "MessagesReactionButtonExtraPreview", + "MessagesViewWithIdentityChangePreview", "MessagesViewWithTypingPreview", "PageTitleWithIconFullPreview", "PageTitleWithIconMinimalPreview", diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Day_0_en.png new file mode 100644 index 0000000000..1b6fb4bab8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650 +size 3642 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Day_1_en.png new file mode 100644 index 0000000000..66783ec6cc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:24481773bfccc5eb1ebf3f9955cdc77e8b3b5130d4fa56f96df732e3627ea3c6 +size 21018 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Night_0_en.png new file mode 100644 index 0000000000..d6fd8eeb70 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd +size 3659 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Night_1_en.png new file mode 100644 index 0000000000..93bd368a94 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_IdentityChangeStateView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1421adb601d9a8050a5ed1b60aba8a05b8eab61aaf18d3936226efe891acd8b6 +size 23880 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_0_en.png new file mode 100644 index 0000000000..00407fa4bb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b79329ddb864ea2100974330facffd3a50d1cb60935ec6079a56760fbe8f57e7 +size 54972 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_1_en.png new file mode 100644 index 0000000000..fec7097b6a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d37a9ffee50f8e9ea0c9fd50c61310969c4fbaa99cf858481b3f42f8db4467d +size 61102 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_0_en.png new file mode 100644 index 0000000000..e6322f47bc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec81ab9e31dd4a2aad6ff8ac92a1bcabbf7f807c8e6ca8b91873ed6706f8af05 +size 55396 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en.png new file mode 100644 index 0000000000..2cbc735bd8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0952d9cf3812ca419e144936faf523e0b865a22a61d2a55e07f089d9e6e9009 +size 64824 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_0_en.png new file mode 100644 index 0000000000..eb5f42c584 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e631689b398ff2b91560c753043a9c7b4b25be7b3fdc2f3a3a0f00e2bf2db00d +size 20713 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_1_en.png new file mode 100644 index 0000000000..2049499119 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1aec7a502a744d81e6e9f1dd9f5730b66c5ace3259b5e83a9304c69286806590 +size 19999 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_0_en.png new file mode 100644 index 0000000000..48d2bc92a3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f74c2ee3d31418214fa50f2829eb71178a7500401a72c8f46d048925eba2d462 +size 23389 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_1_en.png new file mode 100644 index 0000000000..e0e5d98f4e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7c17e0c95c2e48e2ac0f4ce7a7cd97bf255a8d0e304146808ef1837e1c951e73 +size 23177 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_42_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_42_en.png index f3b2441e94..c48fdc121e 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_42_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_42_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1eb10a2627a2a7be8af030a305cbec963f6d3f03358d9acdfe1d58e852247759 -size 16058 +oid sha256:88eac082691b8314a06f16820f7ace62321569c3fa633cb243ee9ba508dfb7bd +size 15712 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_43_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_43_en.png index 1136535f88..835b3d987a 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_43_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_43_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8d294591f355f604bb101abd6b7f27fe4f6d884d9d561baf46fc45e6d8111fd6 -size 15332 +oid sha256:fd4f7a9468c8db222fbc3631ad4bf7876d80bb31ef1d79292ad72d01f20546f2 +size 14951 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_44_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_44_en.png index ee7f8a89cc..0565f21473 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_44_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_44_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:96518c010196d80e8696ffcaea032468bb4d1edfbe6b1187286da488600363bf -size 17891 +oid sha256:fe30a0d96effe257973c893b6450a357d49f11e0f4743b2fdb16050fc15b3a8f +size 17549 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_45_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_45_en.png index 5088cf47a4..f3b2441e94 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_45_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_45_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:af4a8ea34761a5af0f14f979e9dc83680703a9ff8e5ce53ee0bcc4b36a39aa3d -size 19231 +oid sha256:1eb10a2627a2a7be8af030a305cbec963f6d3f03358d9acdfe1d58e852247759 +size 16058 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_46_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_46_en.png index 301367e005..1136535f88 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_46_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_46_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a3808fc8a5a2160144e93508dbcb09b62e52e9f7f83cf4ec8d8e7a20c7cf554 -size 18469 +oid sha256:8d294591f355f604bb101abd6b7f27fe4f6d884d9d561baf46fc45e6d8111fd6 +size 15332 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_47_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_47_en.png index 83586a0c51..ee7f8a89cc 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_47_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_47_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9dd0232c7847d5c0bb1047cfc6be5d4d43d2cc2e9347aff70a793103b511ad98 -size 21073 +oid sha256:96518c010196d80e8696ffcaea032468bb4d1edfbe6b1187286da488600363bf +size 17891 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_48_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_48_en.png index 729bc5ae58..5088cf47a4 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_48_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_48_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1424c9540b87a819d8fd4135db963552794ffde39f5915b1bbbc0ec83d747bcf -size 16595 +oid sha256:af4a8ea34761a5af0f14f979e9dc83680703a9ff8e5ce53ee0bcc4b36a39aa3d +size 19231 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_49_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_49_en.png index 315e227c79..301367e005 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_49_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_49_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:53d2bff076eb5a8e855b54f79cd179e57750e24a457b49a64aca9f176d34b294 -size 15348 +oid sha256:4a3808fc8a5a2160144e93508dbcb09b62e52e9f7f83cf4ec8d8e7a20c7cf554 +size 18469 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_50_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_50_en.png index 4a3262b6ac..83586a0c51 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_50_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_50_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d1570611e0a894a88644956558629113717217ce4b7a76c615264bc2fa776616 -size 19763 +oid sha256:9dd0232c7847d5c0bb1047cfc6be5d4d43d2cc2e9347aff70a793103b511ad98 +size 21073 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_51_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_51_en.png index 88e494d6a8..729bc5ae58 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_51_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_51_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:38cb5a55723aa8c0e8044bb732f8013d3cdc030797ef981d5cc867f0c70203bf -size 12923 +oid sha256:1424c9540b87a819d8fd4135db963552794ffde39f5915b1bbbc0ec83d747bcf +size 16595 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_52_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_52_en.png index 8de56ec748..315e227c79 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_52_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_52_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f6458f921eebb7ceebbea2b8e6da2aa18071015e02ef68e4904e3a84cb460e5c -size 12584 +oid sha256:53d2bff076eb5a8e855b54f79cd179e57750e24a457b49a64aca9f176d34b294 +size 15348 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_53_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_53_en.png index b87579d730..4a3262b6ac 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_53_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_53_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:68cb86e7966e17d3240de09ba69aa4af36e4b07c1b669b5003af8071d6eb1ee1 -size 13832 +oid sha256:d1570611e0a894a88644956558629113717217ce4b7a76c615264bc2fa776616 +size 19763 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_54_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_54_en.png index 1bc88154e5..88e494d6a8 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_54_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_54_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:115d72ff7fae6a8673dd1908ef749710042d04c4b37f6cb13ec647682412d22a -size 18643 +oid sha256:38cb5a55723aa8c0e8044bb732f8013d3cdc030797ef981d5cc867f0c70203bf +size 12923 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_55_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_55_en.png index d92dd4a1f6..8de56ec748 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_55_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_55_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:780cc6ffcae4754ae67fcc522a2149a1cd8b20cd1e1441d28c5441a2d0d760a3 -size 17006 +oid sha256:f6458f921eebb7ceebbea2b8e6da2aa18071015e02ef68e4904e3a84cb460e5c +size 12584 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_56_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_56_en.png index 06eb0f06a4..b87579d730 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_56_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_56_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8e196a6ccf55855e8cad09e9934e09c8564cad08b4dcbc418c4a2b8d467088c3 -size 22891 +oid sha256:68cb86e7966e17d3240de09ba69aa4af36e4b07c1b669b5003af8071d6eb1ee1 +size 13832 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_57_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_57_en.png index f9b3e60905..1bc88154e5 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_57_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_57_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5f826889a753d73bbeb71bd9706a21c476f93e448b2491160045392b6472b4da -size 20976 +oid sha256:115d72ff7fae6a8673dd1908ef749710042d04c4b37f6cb13ec647682412d22a +size 18643 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_58_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_58_en.png index 92617a1ff8..d92dd4a1f6 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_58_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_58_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:efa1c5a898ee842b444115e45eb57c1c1624d84448fea79436a7b153f97d0c73 -size 19356 +oid sha256:780cc6ffcae4754ae67fcc522a2149a1cd8b20cd1e1441d28c5441a2d0d760a3 +size 17006 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_59_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_59_en.png index bc0c7cda4e..06eb0f06a4 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_59_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_59_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:746b7181d7f9a3c25040624a2c20378b1eff1ce299840c6f4b4b9ca3f877bdfd -size 24976 +oid sha256:8e196a6ccf55855e8cad09e9934e09c8564cad08b4dcbc418c4a2b8d467088c3 +size 22891 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_60_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_60_en.png index 1d8756b909..f9b3e60905 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_60_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_60_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f3b7c704d2aa78d7ca48ea8a2c44e4038a543e3a42df9adb6170a9d8e8497335 -size 16661 +oid sha256:5f826889a753d73bbeb71bd9706a21c476f93e448b2491160045392b6472b4da +size 20976 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_61_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_61_en.png index 4391b4759f..92617a1ff8 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_61_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_61_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c403904d27f1b0c7b7964e8ddaf96b27df9f7e660c67fca72849a53591b8360d -size 15912 +oid sha256:efa1c5a898ee842b444115e45eb57c1c1624d84448fea79436a7b153f97d0c73 +size 19356 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_62_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_62_en.png index dd81493a76..bc0c7cda4e 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_62_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_62_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:67a8223f107fa05f1c5d66efc85f68325b4bf835e371ad44553bd6d6edd4a201 -size 18491 +oid sha256:746b7181d7f9a3c25040624a2c20378b1eff1ce299840c6f4b4b9ca3f877bdfd +size 24976 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_63_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_63_en.png index ae4113574c..1d8756b909 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_63_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_63_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76077b390e22e005eee695d0da5dc3835182214675d4e1b5aeb1f05cf5614ff6 -size 21544 +oid sha256:f3b7c704d2aa78d7ca48ea8a2c44e4038a543e3a42df9adb6170a9d8e8497335 +size 16661 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_64_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_64_en.png index f233bfc0ce..4391b4759f 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_64_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_64_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2a624a14ff7ca0fb65e89e69357eb551ee4fd4aab4cebbb38cfb017fdca8f832 -size 20702 +oid sha256:c403904d27f1b0c7b7964e8ddaf96b27df9f7e660c67fca72849a53591b8360d +size 15912 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_65_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_65_en.png index c1712835ff..dd81493a76 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_65_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_65_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:be8c830118af26d3d39a80d5dfa3a750e6e3830a30b066ca91b7ed305a2ee482 -size 23624 +oid sha256:67a8223f107fa05f1c5d66efc85f68325b4bf835e371ad44553bd6d6edd4a201 +size 18491 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_66_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_66_en.png index 4750a7f71d..ae4113574c 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_66_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_66_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:631ae3b88cd0be00dfc4ceda6ecfd49f56526c2605c5c3c1f3975443a6a726b8 -size 17310 +oid sha256:76077b390e22e005eee695d0da5dc3835182214675d4e1b5aeb1f05cf5614ff6 +size 21544 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_67_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_67_en.png index 643a670bbd..f233bfc0ce 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_67_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_67_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c2105c726a015292a180feb55164181d331b9f162e47152d36d49d6ccbe6e3fc -size 16460 +oid sha256:2a624a14ff7ca0fb65e89e69357eb551ee4fd4aab4cebbb38cfb017fdca8f832 +size 20702 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_68_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_68_en.png index 0b8c15e54c..c1712835ff 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_68_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_68_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:672f829a7227921b7179e91c1ee3ef58ff2b7dab7f131b687c4fe0b9862d2a2c -size 19436 +oid sha256:be8c830118af26d3d39a80d5dfa3a750e6e3830a30b066ca91b7ed305a2ee482 +size 23624 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_69_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_69_en.png index 1f85375406..4750a7f71d 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_69_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_69_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e66799a7ec09998d9377fae8f6ff79c46cf8978a6c2a95c7fff7f21f55bd87f3 -size 20976 +oid sha256:631ae3b88cd0be00dfc4ceda6ecfd49f56526c2605c5c3c1f3975443a6a726b8 +size 17310 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_70_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_70_en.png index f097d7b073..643a670bbd 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_70_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_70_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c2db8f5b2d57debf2dfc4dbfe807ebe8278d27994242761abb709119374c9786 -size 18769 +oid sha256:c2105c726a015292a180feb55164181d331b9f162e47152d36d49d6ccbe6e3fc +size 16460 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_71_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_71_en.png index fc23c2c5ad..0b8c15e54c 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_71_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_71_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2f3a7f0a4944f35fd726d5090eab08140b57332e6f7d8eb475fc6bf9ef37bcdf -size 26033 +oid sha256:672f829a7227921b7179e91c1ee3ef58ff2b7dab7f131b687c4fe0b9862d2a2c +size 19436 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_72_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_72_en.png index e2704b5cc5..1f85375406 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_72_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_72_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ad1c15d26a5f2711585276388d3595a997249e336950937a57c7beecd4b6b984 -size 14956 +oid sha256:e66799a7ec09998d9377fae8f6ff79c46cf8978a6c2a95c7fff7f21f55bd87f3 +size 20976 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_73_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_73_en.png index 55b9858dfb..f097d7b073 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_73_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_73_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ec1f5a41d1039cc3f93011fc6d05420964cc0d5d53db393c47a6dd9916588602 -size 14211 +oid sha256:c2db8f5b2d57debf2dfc4dbfe807ebe8278d27994242761abb709119374c9786 +size 18769 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_74_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_74_en.png index b294ff8e75..fc23c2c5ad 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_74_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_74_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e88e6991bca9f0c99c28b6126b10135d0ba8de1faa7bc6174e6f66bc11b2ce03 -size 16794 +oid sha256:2f3a7f0a4944f35fd726d5090eab08140b57332e6f7d8eb475fc6bf9ef37bcdf +size 26033 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_75_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_75_en.png new file mode 100644 index 0000000000..e2704b5cc5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_75_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad1c15d26a5f2711585276388d3595a997249e336950937a57c7beecd4b6b984 +size 14956 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_76_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_76_en.png new file mode 100644 index 0000000000..55b9858dfb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_76_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec1f5a41d1039cc3f93011fc6d05420964cc0d5d53db393c47a6dd9916588602 +size 14211 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_77_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_77_en.png new file mode 100644 index 0000000000..b294ff8e75 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_77_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e88e6991bca9f0c99c28b6126b10135d0ba8de1faa7bc6174e6f66bc11b2ce03 +size 16794