Merge pull request #3621 from element-hq/feature/bma/composerAlert

Warn the user when unverified user has changed their identity
This commit is contained in:
Benoit Marty 2024-10-08 16:33:14 +02:00 committed by GitHub
commit 0ffd787187
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 967 additions and 210 deletions

View file

@ -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"
}

View file

@ -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<Callback>()
@ -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,

View file

@ -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<VoiceMessageComposerState>,
timelinePresenterFactory: TimelinePresenter.Factory,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
private val actionListPresenterFactory: ActionListPresenter.Factory,
private val customReactionPresenter: Presenter<CustomReactionState>,
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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
}

View file

@ -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<RoomMemberIdentityStateChange>,
val eventSink: (IdentityChangeEvent) -> Unit,
)
data class RoomMemberIdentityStateChange(
val identityRoomMember: IdentityRoomMember,
val identityState: IdentityState,
)

View file

@ -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<IdentityChangeState> {
@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<PersistentList<RoomMemberIdentityStateChange>>.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,
),
)

View file

@ -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<IdentityChangeState> {
override val values: Sequence<IdentityChangeState>
get() = sequenceOf(
anIdentityChangeState(),
anIdentityChangeState(
roomMemberIdentityStateChanges = listOf(
RoomMemberIdentityStateChange(
identityRoomMember = anIdentityRoomMember(disambiguatedDisplayName = "Alice"),
identityState = IdentityState.PinViolation,
),
),
),
)
}
internal fun anIdentityChangeState(
roomMemberIdentityStateChanges: List<RoomMemberIdentityStateChange> = 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,
)

View file

@ -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 = {},
)
}

View file

@ -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,
)

View file

@ -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 = {},
)
}

View file

@ -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<ReadReceiptBottomSheetState>
@Binds
fun bindIdentityChangeStatePresenter(presenter: IdentityChangeStatePresenter): Presenter<IdentityChangeState>
}

View file

@ -33,10 +33,12 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
aTimelineItemTextContent(),
aTimelineItemUnknownContent(),
aTimelineItemTextContent().copy(isEdited = true),
aTimelineItemTextContent(body = "😁")
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT)
)
}
const val AN_EMOJI_ONLY_TEXT = "😁"
class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineItemTextBasedContent> {
private fun buildSpanned(text: String) = buildSpannedString {
inSpans(StyleSpan(Typeface.BOLD)) {

View file

@ -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<TypingNotificationState> {
@Composable
override fun present(): TypingNotificationState {
val typingMembersState = remember { mutableStateOf(emptyList<RoomMember>()) }
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<TypingRoomMember>()
}
}
// 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<List<RoomMember>>) {
private fun ProduceStateScope<ImmutableList<TypingRoomMember>>.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,
)
}

View file

@ -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<RoomMember>,
val typingMembers: ImmutableList<TypingRoomMember>,
/** Whether to reserve space for the typing notifications at the bottom of the timeline. */
val reserveSpace: Boolean,
)

View file

@ -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<TypingNotificationState> {
override val values: Sequence<TypingNotificationState>
get() = sequenceOf(
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
aTypingRoomMember(displayName = "Bob"),
),
),
aTypingNotificationState(
typingMembers = listOf(aTypingRoomMember()),
reserveSpace = true
),
aTypingNotificationState(reserveSpace = true),
)
}

View file

@ -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<TypingNotificationState> {
@ -24,39 +21,39 @@ class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificat
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice", isNameAmbiguous = true),
aTypingRoomMember(disambiguatedDisplayName = "Alice (@alice:example.com)"),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
aTypingRoomMember(displayName = "Bob"),
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
aTypingRoomMember(disambiguatedDisplayName = "Bob"),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
aTypingRoomMember(displayName = "Bob"),
aTypingRoomMember(displayName = "Charlie"),
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
aTypingRoomMember(disambiguatedDisplayName = "Bob"),
aTypingRoomMember(disambiguatedDisplayName = "Charlie"),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
aTypingRoomMember(displayName = "Bob"),
aTypingRoomMember(displayName = "Charlie"),
aTypingRoomMember(displayName = "Dan"),
aTypingRoomMember(displayName = "Eve"),
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
aTypingRoomMember(disambiguatedDisplayName = "Bob"),
aTypingRoomMember(disambiguatedDisplayName = "Charlie"),
aTypingRoomMember(disambiguatedDisplayName = "Dan"),
aTypingRoomMember(disambiguatedDisplayName = "Eve"),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice with a very long display name which means that it will be truncated"),
aTypingRoomMember(disambiguatedDisplayName = "Alice with a very long display name which means that it will be truncated"),
),
),
aTypingNotificationState(
@ -67,7 +64,7 @@ class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificat
}
internal fun aTypingNotificationState(
typingMembers: List<RoomMember> = emptyList(),
typingMembers: List<TypingRoomMember> = 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,
)

View file

@ -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<RoomMember>): AnnotatedString {
private fun computeTypingNotificationText(typingMembers: ImmutableList<TypingRoomMember>): AnnotatedString {
// Remember the last value to avoid empty typing messages while animating
var result by remember { mutableStateOf(AnnotatedString("")) }
if (typingMembers.isNotEmpty()) {

View file

@ -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,
)

View file

@ -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

View file

@ -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(),

View file

@ -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<UserId, Result<Unit>> { 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,
)
}
}

View file

@ -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,

View file

@ -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())
}
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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 = "Alices verified identity has changed. Learn more".toAnnotatedString(),
isCritical = isCritical,
onSubmitClick = {},
)
}

View file

@ -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),

View file

@ -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<IdentityResetHandle?>
/**
* Remember this identity, ensuring it does not result in a pin violation.
*/
suspend fun pinUserIdentity(userId: UserId): Result<Unit>
}
/**

View file

@ -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,
}

View file

@ -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,
)

View file

@ -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<MatrixRoomInfo>
val roomTypingMembersFlow: Flow<List<UserId>>
val identityStateChangesFlow: Flow<List<IdentityStateChange>>
/**
* A one-to-one is a room with exactly 2 members.

View file

@ -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<Unit> = runCatching {
val userIdentity = service.getUserIdentity(userId.value) ?: error("User identity not found")
userIdentity.pin()
}
}

View file

@ -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
}

View file

@ -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<List<IdentityStateChange>> = mxCallbackFlow {
val initial = emptyList<IdentityStateChange>()
channel.trySend(initial)
innerRoom.subscribeToIdentityStatusChanges(object : IdentityStatusChangeListener {
override fun call(identityStatusChange: List<RustIdentityStateChange>) {
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)

View file

@ -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<IdentityResetHandle?> = { lambdaError() },
private val pinUserIdentityResult: (UserId) -> Result<Unit> = { lambdaError() },
) : EncryptionService {
private var disableRecoveryFailure: Exception? = null
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
@ -117,6 +119,10 @@ class FakeEncryptionService(
return startIdentityResetLambda()
}
override suspend fun pinUserIdentity(userId: UserId): Result<Unit> {
return pinUserIdentityResult(userId)
}
companion object {
const val FAKE_RECOVERY_KEY = "fake"
}

View file

@ -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<UserId, List<DeviceId>>, TransactionId) -> Result<Unit> = { _, _ -> lambdaError() },
private val withdrawVerificationAndResendResult: (List<UserId>, TransactionId) -> Result<Unit> = { _, _ -> lambdaError() },
) : MatrixRoom {
) : MatrixRoom {
private val _roomInfoFlow: MutableSharedFlow<MatrixRoomInfo> = MutableSharedFlow(replay = 1)
override val roomInfoFlow: Flow<MatrixRoomInfo> = _roomInfoFlow
@ -152,6 +153,13 @@ class FakeMatrixRoom(
_roomTypingMembersFlow.tryEmit(typingMembers)
}
private val _identityStateChangesFlow: MutableSharedFlow<List<IdentityStateChange>> = MutableSharedFlow(replay = 1)
override val identityStateChangesFlow: Flow<List<IdentityStateChange>> = _identityStateChangesFlow
fun emitIdentityStateChanges(identityStateChanges: List<IdentityStateChange>) {
_identityStateChangesFlow.tryEmit(identityStateChanges)
}
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
override val roomNotificationSettingsStateFlow: MutableStateFlow<MatrixRoomNotificationSettingsState> =

View file

@ -75,6 +75,7 @@ class KonsistPreviewTest {
"MessageComposerViewVoicePreview",
"MessagesReactionButtonAddPreview",
"MessagesReactionButtonExtraPreview",
"MessagesViewWithIdentityChangePreview",
"MessagesViewWithTypingPreview",
"PageTitleWithIconFullPreview",
"PageTitleWithIconMinimalPreview",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1eb10a2627a2a7be8af030a305cbec963f6d3f03358d9acdfe1d58e852247759
size 16058
oid sha256:88eac082691b8314a06f16820f7ace62321569c3fa633cb243ee9ba508dfb7bd
size 15712

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8d294591f355f604bb101abd6b7f27fe4f6d884d9d561baf46fc45e6d8111fd6
size 15332
oid sha256:fd4f7a9468c8db222fbc3631ad4bf7876d80bb31ef1d79292ad72d01f20546f2
size 14951

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:96518c010196d80e8696ffcaea032468bb4d1edfbe6b1187286da488600363bf
size 17891
oid sha256:fe30a0d96effe257973c893b6450a357d49f11e0f4743b2fdb16050fc15b3a8f
size 17549

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:af4a8ea34761a5af0f14f979e9dc83680703a9ff8e5ce53ee0bcc4b36a39aa3d
size 19231
oid sha256:1eb10a2627a2a7be8af030a305cbec963f6d3f03358d9acdfe1d58e852247759
size 16058

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4a3808fc8a5a2160144e93508dbcb09b62e52e9f7f83cf4ec8d8e7a20c7cf554
size 18469
oid sha256:8d294591f355f604bb101abd6b7f27fe4f6d884d9d561baf46fc45e6d8111fd6
size 15332

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9dd0232c7847d5c0bb1047cfc6be5d4d43d2cc2e9347aff70a793103b511ad98
size 21073
oid sha256:96518c010196d80e8696ffcaea032468bb4d1edfbe6b1187286da488600363bf
size 17891

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1424c9540b87a819d8fd4135db963552794ffde39f5915b1bbbc0ec83d747bcf
size 16595
oid sha256:af4a8ea34761a5af0f14f979e9dc83680703a9ff8e5ce53ee0bcc4b36a39aa3d
size 19231

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:53d2bff076eb5a8e855b54f79cd179e57750e24a457b49a64aca9f176d34b294
size 15348
oid sha256:4a3808fc8a5a2160144e93508dbcb09b62e52e9f7f83cf4ec8d8e7a20c7cf554
size 18469

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d1570611e0a894a88644956558629113717217ce4b7a76c615264bc2fa776616
size 19763
oid sha256:9dd0232c7847d5c0bb1047cfc6be5d4d43d2cc2e9347aff70a793103b511ad98
size 21073

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:38cb5a55723aa8c0e8044bb732f8013d3cdc030797ef981d5cc867f0c70203bf
size 12923
oid sha256:1424c9540b87a819d8fd4135db963552794ffde39f5915b1bbbc0ec83d747bcf
size 16595

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f6458f921eebb7ceebbea2b8e6da2aa18071015e02ef68e4904e3a84cb460e5c
size 12584
oid sha256:53d2bff076eb5a8e855b54f79cd179e57750e24a457b49a64aca9f176d34b294
size 15348

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:68cb86e7966e17d3240de09ba69aa4af36e4b07c1b669b5003af8071d6eb1ee1
size 13832
oid sha256:d1570611e0a894a88644956558629113717217ce4b7a76c615264bc2fa776616
size 19763

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:115d72ff7fae6a8673dd1908ef749710042d04c4b37f6cb13ec647682412d22a
size 18643
oid sha256:38cb5a55723aa8c0e8044bb732f8013d3cdc030797ef981d5cc867f0c70203bf
size 12923

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:780cc6ffcae4754ae67fcc522a2149a1cd8b20cd1e1441d28c5441a2d0d760a3
size 17006
oid sha256:f6458f921eebb7ceebbea2b8e6da2aa18071015e02ef68e4904e3a84cb460e5c
size 12584

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8e196a6ccf55855e8cad09e9934e09c8564cad08b4dcbc418c4a2b8d467088c3
size 22891
oid sha256:68cb86e7966e17d3240de09ba69aa4af36e4b07c1b669b5003af8071d6eb1ee1
size 13832

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5f826889a753d73bbeb71bd9706a21c476f93e448b2491160045392b6472b4da
size 20976
oid sha256:115d72ff7fae6a8673dd1908ef749710042d04c4b37f6cb13ec647682412d22a
size 18643

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:efa1c5a898ee842b444115e45eb57c1c1624d84448fea79436a7b153f97d0c73
size 19356
oid sha256:780cc6ffcae4754ae67fcc522a2149a1cd8b20cd1e1441d28c5441a2d0d760a3
size 17006

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:746b7181d7f9a3c25040624a2c20378b1eff1ce299840c6f4b4b9ca3f877bdfd
size 24976
oid sha256:8e196a6ccf55855e8cad09e9934e09c8564cad08b4dcbc418c4a2b8d467088c3
size 22891

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f3b7c704d2aa78d7ca48ea8a2c44e4038a543e3a42df9adb6170a9d8e8497335
size 16661
oid sha256:5f826889a753d73bbeb71bd9706a21c476f93e448b2491160045392b6472b4da
size 20976

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c403904d27f1b0c7b7964e8ddaf96b27df9f7e660c67fca72849a53591b8360d
size 15912
oid sha256:efa1c5a898ee842b444115e45eb57c1c1624d84448fea79436a7b153f97d0c73
size 19356

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:67a8223f107fa05f1c5d66efc85f68325b4bf835e371ad44553bd6d6edd4a201
size 18491
oid sha256:746b7181d7f9a3c25040624a2c20378b1eff1ce299840c6f4b4b9ca3f877bdfd
size 24976

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:76077b390e22e005eee695d0da5dc3835182214675d4e1b5aeb1f05cf5614ff6
size 21544
oid sha256:f3b7c704d2aa78d7ca48ea8a2c44e4038a543e3a42df9adb6170a9d8e8497335
size 16661

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2a624a14ff7ca0fb65e89e69357eb551ee4fd4aab4cebbb38cfb017fdca8f832
size 20702
oid sha256:c403904d27f1b0c7b7964e8ddaf96b27df9f7e660c67fca72849a53591b8360d
size 15912

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:be8c830118af26d3d39a80d5dfa3a750e6e3830a30b066ca91b7ed305a2ee482
size 23624
oid sha256:67a8223f107fa05f1c5d66efc85f68325b4bf835e371ad44553bd6d6edd4a201
size 18491

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:631ae3b88cd0be00dfc4ceda6ecfd49f56526c2605c5c3c1f3975443a6a726b8
size 17310
oid sha256:76077b390e22e005eee695d0da5dc3835182214675d4e1b5aeb1f05cf5614ff6
size 21544

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c2105c726a015292a180feb55164181d331b9f162e47152d36d49d6ccbe6e3fc
size 16460
oid sha256:2a624a14ff7ca0fb65e89e69357eb551ee4fd4aab4cebbb38cfb017fdca8f832
size 20702

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:672f829a7227921b7179e91c1ee3ef58ff2b7dab7f131b687c4fe0b9862d2a2c
size 19436
oid sha256:be8c830118af26d3d39a80d5dfa3a750e6e3830a30b066ca91b7ed305a2ee482
size 23624

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e66799a7ec09998d9377fae8f6ff79c46cf8978a6c2a95c7fff7f21f55bd87f3
size 20976
oid sha256:631ae3b88cd0be00dfc4ceda6ecfd49f56526c2605c5c3c1f3975443a6a726b8
size 17310

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c2db8f5b2d57debf2dfc4dbfe807ebe8278d27994242761abb709119374c9786
size 18769
oid sha256:c2105c726a015292a180feb55164181d331b9f162e47152d36d49d6ccbe6e3fc
size 16460

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2f3a7f0a4944f35fd726d5090eab08140b57332e6f7d8eb475fc6bf9ef37bcdf
size 26033
oid sha256:672f829a7227921b7179e91c1ee3ef58ff2b7dab7f131b687c4fe0b9862d2a2c
size 19436

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ad1c15d26a5f2711585276388d3595a997249e336950937a57c7beecd4b6b984
size 14956
oid sha256:e66799a7ec09998d9377fae8f6ff79c46cf8978a6c2a95c7fff7f21f55bd87f3
size 20976

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ec1f5a41d1039cc3f93011fc6d05420964cc0d5d53db393c47a6dd9916588602
size 14211
oid sha256:c2db8f5b2d57debf2dfc4dbfe807ebe8278d27994242761abb709119374c9786
size 18769

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e88e6991bca9f0c99c28b6126b10135d0ba8de1faa7bc6174e6f66bc11b2ce03
size 16794
oid sha256:2f3a7f0a4944f35fd726d5090eab08140b57332e6f7d8eb475fc6bf9ef37bcdf
size 26033

View file

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

View file

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

View file

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