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:
commit
0534cdab7e
88 changed files with 967 additions and 210 deletions
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
package io.element.android.appconfig
|
package io.element.android.appconfig
|
||||||
|
|
||||||
object SecureBackupConfig {
|
object LearnMoreConfig {
|
||||||
const val LEARN_MORE_URL: String = "https://element.io/help#encryption5"
|
const val SECURE_BACKUP_URL: String = "https://element.io/help#encryption5"
|
||||||
|
const val IDENTITY_CHANGE_URL: String = "https://element.io/help#encryption18"
|
||||||
}
|
}
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl
|
package io.element.android.features.messages.impl
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
|
@ -26,19 +27,19 @@ import com.bumble.appyx.core.plugin.plugins
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
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.attachments.Attachment
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
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.TimelineEvents
|
||||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
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.di.TimelineItemPresenterFactories
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
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.androidutils.system.toast
|
||||||
import io.element.android.libraries.architecture.NodeInputs
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
import io.element.android.libraries.architecture.inputs
|
import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.core.bool.orFalse
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
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.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
|
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
|
@ -63,8 +64,6 @@ class MessagesNode @AssistedInject constructor(
|
||||||
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
|
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
|
||||||
private val mediaPlayer: MediaPlayer,
|
private val mediaPlayer: MediaPlayer,
|
||||||
private val permalinkParser: PermalinkParser,
|
private val permalinkParser: PermalinkParser,
|
||||||
@ApplicationContext
|
|
||||||
private val context: Context,
|
|
||||||
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||||
private val presenter = presenterFactory.create(this)
|
private val presenter = presenterFactory.create(this)
|
||||||
private val callbacks = plugins<Callback>()
|
private val callbacks = plugins<Callback>()
|
||||||
|
|
@ -124,7 +123,8 @@ class MessagesNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onLinkClick(
|
private fun onLinkClick(
|
||||||
context: Context,
|
activity: Activity,
|
||||||
|
darkTheme: Boolean,
|
||||||
url: String,
|
url: String,
|
||||||
eventSink: (TimelineEvents) -> Unit,
|
eventSink: (TimelineEvents) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|
@ -135,16 +135,20 @@ class MessagesNode @AssistedInject constructor(
|
||||||
callbacks.forEach { it.onUserDataClick(permalink.userId) }
|
callbacks.forEach { it.onUserDataClick(permalink.userId) }
|
||||||
}
|
}
|
||||||
is PermalinkData.RoomLink -> {
|
is PermalinkData.RoomLink -> {
|
||||||
handleRoomLinkClick(permalink, eventSink)
|
handleRoomLinkClick(activity, permalink, eventSink)
|
||||||
}
|
}
|
||||||
is PermalinkData.FallbackLink,
|
is PermalinkData.FallbackLink,
|
||||||
is PermalinkData.RoomEmailInviteLink -> {
|
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)) {
|
if (room.matches(roomLink.roomIdOrAlias)) {
|
||||||
val eventId = roomLink.eventId
|
val eventId = roomLink.eventId
|
||||||
if (eventId != null) {
|
if (eventId != null) {
|
||||||
|
|
@ -192,7 +196,8 @@ class MessagesNode @AssistedInject constructor(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val context = LocalContext.current
|
val activity = LocalContext.current as Activity
|
||||||
|
val isDark = ElementTheme.isLightTheme.not()
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
|
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
|
||||||
) {
|
) {
|
||||||
|
|
@ -210,7 +215,7 @@ class MessagesNode @AssistedInject constructor(
|
||||||
onEventClick = this::onEventClick,
|
onEventClick = this::onEventClick,
|
||||||
onPreviewAttachments = this::onPreviewAttachments,
|
onPreviewAttachments = this::onPreviewAttachments,
|
||||||
onUserDataClick = this::onUserDataClick,
|
onUserDataClick = this::onUserDataClick,
|
||||||
onLinkClick = { onLinkClick(context, it, state.timelineState.eventSink) },
|
onLinkClick = { url -> onLinkClick(activity, isDark, url, state.timelineState.eventSink) },
|
||||||
onSendLocationClick = this::onSendLocationClick,
|
onSendLocationClick = this::onSendLocationClick,
|
||||||
onCreatePollClick = this::onCreatePollClick,
|
onCreatePollClick = this::onCreatePollClick,
|
||||||
onJoinCallClick = this::onJoinCallClick,
|
onJoinCallClick = this::onJoinCallClick,
|
||||||
|
|
|
||||||
|
|
@ -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.ActionListPresenter
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
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.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.MessageComposerEvents
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
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.pinned.banner.PinnedMessagesBannerState
|
||||||
|
|
@ -91,6 +92,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
private val voiceMessageComposerPresenter: Presenter<VoiceMessageComposerState>,
|
private val voiceMessageComposerPresenter: Presenter<VoiceMessageComposerState>,
|
||||||
timelinePresenterFactory: TimelinePresenter.Factory,
|
timelinePresenterFactory: TimelinePresenter.Factory,
|
||||||
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
|
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
|
||||||
|
private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
|
||||||
private val actionListPresenterFactory: ActionListPresenter.Factory,
|
private val actionListPresenterFactory: ActionListPresenter.Factory,
|
||||||
private val customReactionPresenter: Presenter<CustomReactionState>,
|
private val customReactionPresenter: Presenter<CustomReactionState>,
|
||||||
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
|
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
|
||||||
|
|
@ -125,6 +127,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
val voiceMessageComposerState = voiceMessageComposerPresenter.present()
|
val voiceMessageComposerState = voiceMessageComposerPresenter.present()
|
||||||
val timelineState = timelinePresenter.present()
|
val timelineState = timelinePresenter.present()
|
||||||
val timelineProtectionState = timelineProtectionPresenter.present()
|
val timelineProtectionState = timelineProtectionPresenter.present()
|
||||||
|
val identityChangeState = identityChangeStatePresenter.present()
|
||||||
val actionListState = actionListPresenter.present()
|
val actionListState = actionListPresenter.present()
|
||||||
val customReactionState = customReactionPresenter.present()
|
val customReactionState = customReactionPresenter.present()
|
||||||
val reactionSummaryState = reactionSummaryPresenter.present()
|
val reactionSummaryState = reactionSummaryPresenter.present()
|
||||||
|
|
@ -217,6 +220,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
voiceMessageComposerState = voiceMessageComposerState,
|
voiceMessageComposerState = voiceMessageComposerState,
|
||||||
timelineState = timelineState,
|
timelineState = timelineState,
|
||||||
timelineProtectionState = timelineProtectionState,
|
timelineProtectionState = timelineProtectionState,
|
||||||
|
identityChangeState = identityChangeState,
|
||||||
actionListState = actionListState,
|
actionListState = actionListState,
|
||||||
customReactionState = customReactionState,
|
customReactionState = customReactionState,
|
||||||
reactionSummaryState = reactionSummaryState,
|
reactionSummaryState = reactionSummaryState,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ package io.element.android.features.messages.impl
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
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.messagecomposer.MessageComposerState
|
||||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||||
|
|
@ -34,6 +35,7 @@ data class MessagesState(
|
||||||
val voiceMessageComposerState: VoiceMessageComposerState,
|
val voiceMessageComposerState: VoiceMessageComposerState,
|
||||||
val timelineState: TimelineState,
|
val timelineState: TimelineState,
|
||||||
val timelineProtectionState: TimelineProtectionState,
|
val timelineProtectionState: TimelineProtectionState,
|
||||||
|
val identityChangeState: IdentityChangeState,
|
||||||
val actionListState: ActionListState,
|
val actionListState: ActionListState,
|
||||||
val customReactionState: CustomReactionState,
|
val customReactionState: CustomReactionState,
|
||||||
val reactionSummaryState: ReactionSummaryState,
|
val reactionSummaryState: ReactionSummaryState,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ package io.element.android.features.messages.impl
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
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.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.AttachmentsState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
||||||
|
|
@ -106,6 +108,7 @@ fun aMessagesState(
|
||||||
focusedEventIndex = 2,
|
focusedEventIndex = 2,
|
||||||
),
|
),
|
||||||
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
|
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
|
||||||
|
identityChangeState: IdentityChangeState = anIdentityChangeState(),
|
||||||
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
|
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
|
||||||
actionListState: ActionListState = anActionListState(),
|
actionListState: ActionListState = anActionListState(),
|
||||||
customReactionState: CustomReactionState = aCustomReactionState(),
|
customReactionState: CustomReactionState = aCustomReactionState(),
|
||||||
|
|
@ -125,6 +128,7 @@ fun aMessagesState(
|
||||||
composerState = composerState,
|
composerState = composerState,
|
||||||
voiceMessageComposerState = voiceMessageComposerState,
|
voiceMessageComposerState = voiceMessageComposerState,
|
||||||
timelineProtectionState = timelineProtectionState,
|
timelineProtectionState = timelineProtectionState,
|
||||||
|
identityChangeState = identityChangeState,
|
||||||
timelineState = timelineState,
|
timelineState = timelineState,
|
||||||
readReceiptBottomSheetState = readReceiptBottomSheetState,
|
readReceiptBottomSheetState = readReceiptBottomSheetState,
|
||||||
actionListState = actionListState,
|
actionListState = actionListState,
|
||||||
|
|
|
||||||
|
|
@ -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.ActionListView
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
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.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.AttachmentsBottomSheet
|
||||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
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.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -415,6 +417,7 @@ private fun MessagesViewContent(
|
||||||
MessagesViewComposerBottomSheetContents(
|
MessagesViewComposerBottomSheetContents(
|
||||||
subcomposing = subcomposing,
|
subcomposing = subcomposing,
|
||||||
state = state,
|
state = state,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
sheetContentKey = sheetResizeContentKey.intValue,
|
sheetContentKey = sheetResizeContentKey.intValue,
|
||||||
|
|
@ -428,6 +431,7 @@ private fun MessagesViewContent(
|
||||||
private fun MessagesViewComposerBottomSheetContents(
|
private fun MessagesViewComposerBottomSheetContents(
|
||||||
subcomposing: Boolean,
|
subcomposing: Boolean,
|
||||||
state: MessagesState,
|
state: MessagesState,
|
||||||
|
onLinkClick: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (state.userEventPermissions.canSendMessage) {
|
if (state.userEventPermissions.canSendMessage) {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
|
@ -448,6 +452,14 @@ private fun MessagesViewComposerBottomSheetContents(
|
||||||
state.composerState.eventSink(MessageComposerEvents.InsertSuggestion(it))
|
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(
|
MessageComposerView(
|
||||||
state = state.composerState,
|
state = state.composerState,
|
||||||
voiceMessageState = state.voiceMessageComposerState,
|
voiceMessageState = state.voiceMessageComposerState,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,8 @@ package io.element.android.features.messages.impl.di
|
||||||
import com.squareup.anvil.annotations.ContributesTo
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
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.ResolveVerifiedUserSendFailurePresenter
|
||||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
|
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||||
|
|
@ -60,4 +62,7 @@ interface MessagesModule {
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
fun bindReadReceiptBottomSheetPresenter(presenter: ReadReceiptBottomSheetPresenter): Presenter<ReadReceiptBottomSheetState>
|
fun bindReadReceiptBottomSheetPresenter(presenter: ReadReceiptBottomSheetPresenter): Presenter<ReadReceiptBottomSheetState>
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
fun bindIdentityChangeStatePresenter(presenter: IdentityChangeStatePresenter): Presenter<IdentityChangeState>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,12 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
||||||
aTimelineItemTextContent(),
|
aTimelineItemTextContent(),
|
||||||
aTimelineItemUnknownContent(),
|
aTimelineItemUnknownContent(),
|
||||||
aTimelineItemTextContent().copy(isEdited = true),
|
aTimelineItemTextContent().copy(isEdited = true),
|
||||||
aTimelineItemTextContent(body = "😁")
|
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const val AN_EMOJI_ONLY_TEXT = "😁"
|
||||||
|
|
||||||
class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineItemTextBasedContent> {
|
class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineItemTextBasedContent> {
|
||||||
private fun buildSpanned(text: String) = buildSpannedString {
|
private fun buildSpanned(text: String) = buildSpannedString {
|
||||||
inSpans(StyleSpan(Typeface.BOLD)) {
|
inSpans(StyleSpan(Typeface.BOLD)) {
|
||||||
|
|
|
||||||
|
|
@ -9,21 +9,22 @@ package io.element.android.features.messages.impl.typing
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.ProduceStateScope
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
|
||||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
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.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
|
@ -36,64 +37,56 @@ class TypingNotificationPresenter @Inject constructor(
|
||||||
) : Presenter<TypingNotificationState> {
|
) : Presenter<TypingNotificationState> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): TypingNotificationState {
|
override fun present(): TypingNotificationState {
|
||||||
val typingMembersState = remember { mutableStateOf(emptyList<RoomMember>()) }
|
|
||||||
val renderTypingNotifications by sessionPreferencesStore.isRenderTypingNotificationsEnabled().collectAsState(initial = true)
|
val renderTypingNotifications by sessionPreferencesStore.isRenderTypingNotificationsEnabled().collectAsState(initial = true)
|
||||||
|
val typingMembersState by produceState(initialValue = persistentListOf(), key1 = renderTypingNotifications) {
|
||||||
LaunchedEffect(renderTypingNotifications) {
|
|
||||||
if (renderTypingNotifications) {
|
if (renderTypingNotifications) {
|
||||||
observeRoomTypingMembers(typingMembersState)
|
observeRoomTypingMembers()
|
||||||
} else {
|
} else {
|
||||||
typingMembersState.value = emptyList()
|
value = persistentListOf<TypingRoomMember>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This will keep the space reserved for the typing notifications after the first one is displayed
|
// This will keep the space reserved for the typing notifications after the first one is displayed
|
||||||
var reserveSpace by remember { mutableStateOf(false) }
|
var reserveSpace by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(renderTypingNotifications, typingMembersState.value) {
|
LaunchedEffect(renderTypingNotifications, typingMembersState) {
|
||||||
if (renderTypingNotifications && typingMembersState.value.isNotEmpty()) {
|
if (renderTypingNotifications && typingMembersState.isNotEmpty()) {
|
||||||
reserveSpace = true
|
reserveSpace = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return TypingNotificationState(
|
return TypingNotificationState(
|
||||||
renderTypingNotifications = renderTypingNotifications,
|
renderTypingNotifications = renderTypingNotifications,
|
||||||
typingMembers = typingMembersState.value.toImmutableList(),
|
typingMembers = typingMembersState,
|
||||||
reserveSpace = reserveSpace,
|
reserveSpace = reserveSpace,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.observeRoomTypingMembers(typingMembersState: MutableState<List<RoomMember>>) {
|
private fun ProduceStateScope<ImmutableList<TypingRoomMember>>.observeRoomTypingMembers() {
|
||||||
combine(room.roomTypingMembersFlow, room.membersStateFlow) { typingMembers, membersState ->
|
combine(room.roomTypingMembersFlow, room.membersStateFlow) { typingMembers, membersState ->
|
||||||
typingMembers
|
typingMembers
|
||||||
.map { userId ->
|
.map { userId ->
|
||||||
membersState.roomMembers()
|
membersState.roomMembers()
|
||||||
?.firstOrNull { roomMember -> roomMember.userId == userId }
|
?.firstOrNull { roomMember -> roomMember.userId == userId }
|
||||||
|
?.toTypingRoomMember()
|
||||||
?: createDefaultRoomMemberForTyping(userId)
|
?: createDefaultRoomMemberForTyping(userId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.onEach { members ->
|
.onEach { members ->
|
||||||
typingMembersState.value = members
|
value = members.toImmutableList()
|
||||||
}
|
}
|
||||||
.launchIn(this)
|
.launchIn(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun RoomMember.toTypingRoomMember(): TypingRoomMember {
|
||||||
* Create a default [RoomMember] for typing events.
|
return TypingRoomMember(
|
||||||
* In this case, only the userId will be used for rendering, other fields are not used, but keep them
|
disambiguatedDisplayName = disambiguatedDisplayName,
|
||||||
* as close as possible to the actual data.
|
)
|
||||||
*/
|
}
|
||||||
private fun createDefaultRoomMemberForTyping(userId: UserId): RoomMember {
|
|
||||||
return RoomMember(
|
private fun createDefaultRoomMemberForTyping(userId: UserId): TypingRoomMember {
|
||||||
userId = userId,
|
return TypingRoomMember(
|
||||||
displayName = null,
|
disambiguatedDisplayName = userId.value,
|
||||||
avatarUrl = null,
|
|
||||||
membership = RoomMembershipState.JOIN,
|
|
||||||
isNameAmbiguous = false,
|
|
||||||
powerLevel = 0,
|
|
||||||
normalizedPowerLevel = 0,
|
|
||||||
isIgnored = false,
|
|
||||||
role = RoomMember.Role.USER,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.typing
|
package io.element.android.features.messages.impl.typing
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -17,7 +16,7 @@ data class TypingNotificationState(
|
||||||
/** Whether to render the typing notifications based on the user's preferences. */
|
/** Whether to render the typing notifications based on the user's preferences. */
|
||||||
val renderTypingNotifications: Boolean,
|
val renderTypingNotifications: Boolean,
|
||||||
/** The room members currently typing. */
|
/** 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. */
|
/** Whether to reserve space for the typing notifications at the bottom of the timeline. */
|
||||||
val reserveSpace: Boolean,
|
val reserveSpace: Boolean,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -8,9 +8,6 @@
|
||||||
package io.element.android.features.messages.impl.typing
|
package io.element.android.features.messages.impl.typing
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
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
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificationState> {
|
class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificationState> {
|
||||||
|
|
@ -24,39 +21,39 @@ class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificat
|
||||||
),
|
),
|
||||||
aTypingNotificationState(
|
aTypingNotificationState(
|
||||||
typingMembers = listOf(
|
typingMembers = listOf(
|
||||||
aTypingRoomMember(displayName = "Alice"),
|
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
aTypingNotificationState(
|
aTypingNotificationState(
|
||||||
typingMembers = listOf(
|
typingMembers = listOf(
|
||||||
aTypingRoomMember(displayName = "Alice", isNameAmbiguous = true),
|
aTypingRoomMember(disambiguatedDisplayName = "Alice (@alice:example.com)"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
aTypingNotificationState(
|
aTypingNotificationState(
|
||||||
typingMembers = listOf(
|
typingMembers = listOf(
|
||||||
aTypingRoomMember(displayName = "Alice"),
|
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
|
||||||
aTypingRoomMember(displayName = "Bob"),
|
aTypingRoomMember(disambiguatedDisplayName = "Bob"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
aTypingNotificationState(
|
aTypingNotificationState(
|
||||||
typingMembers = listOf(
|
typingMembers = listOf(
|
||||||
aTypingRoomMember(displayName = "Alice"),
|
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
|
||||||
aTypingRoomMember(displayName = "Bob"),
|
aTypingRoomMember(disambiguatedDisplayName = "Bob"),
|
||||||
aTypingRoomMember(displayName = "Charlie"),
|
aTypingRoomMember(disambiguatedDisplayName = "Charlie"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
aTypingNotificationState(
|
aTypingNotificationState(
|
||||||
typingMembers = listOf(
|
typingMembers = listOf(
|
||||||
aTypingRoomMember(displayName = "Alice"),
|
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
|
||||||
aTypingRoomMember(displayName = "Bob"),
|
aTypingRoomMember(disambiguatedDisplayName = "Bob"),
|
||||||
aTypingRoomMember(displayName = "Charlie"),
|
aTypingRoomMember(disambiguatedDisplayName = "Charlie"),
|
||||||
aTypingRoomMember(displayName = "Dan"),
|
aTypingRoomMember(disambiguatedDisplayName = "Dan"),
|
||||||
aTypingRoomMember(displayName = "Eve"),
|
aTypingRoomMember(disambiguatedDisplayName = "Eve"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
aTypingNotificationState(
|
aTypingNotificationState(
|
||||||
typingMembers = listOf(
|
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(
|
aTypingNotificationState(
|
||||||
|
|
@ -67,7 +64,7 @@ class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificat
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun aTypingNotificationState(
|
internal fun aTypingNotificationState(
|
||||||
typingMembers: List<RoomMember> = emptyList(),
|
typingMembers: List<TypingRoomMember> = emptyList(),
|
||||||
reserveSpace: Boolean = false,
|
reserveSpace: Boolean = false,
|
||||||
) = TypingNotificationState(
|
) = TypingNotificationState(
|
||||||
renderTypingNotifications = true,
|
renderTypingNotifications = true,
|
||||||
|
|
@ -76,19 +73,7 @@ internal fun aTypingNotificationState(
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun aTypingRoomMember(
|
internal fun aTypingRoomMember(
|
||||||
userId: UserId = UserId("@alice:example.com"),
|
disambiguatedDisplayName: String = "@alice:example.com",
|
||||||
displayName: String? = null,
|
) = TypingRoomMember(
|
||||||
isNameAmbiguous: Boolean = false,
|
disambiguatedDisplayName = disambiguatedDisplayName,
|
||||||
): RoomMember {
|
)
|
||||||
return RoomMember(
|
|
||||||
userId = userId,
|
|
||||||
displayName = displayName,
|
|
||||||
avatarUrl = null,
|
|
||||||
membership = RoomMembershipState.JOIN,
|
|
||||||
isNameAmbiguous = isNameAmbiguous,
|
|
||||||
powerLevel = 0,
|
|
||||||
normalizedPowerLevel = 0,
|
|
||||||
isIgnored = false,
|
|
||||||
role = RoomMember.Role.USER,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@Suppress("MultipleEmitters") // False positive
|
@Suppress("MultipleEmitters") // False positive
|
||||||
|
|
@ -53,7 +52,8 @@ fun TypingNotificationView(
|
||||||
val displayNotifications = state.typingMembers.isNotEmpty() && state.renderTypingNotifications
|
val displayNotifications = state.typingMembers.isNotEmpty() && state.renderTypingNotifications
|
||||||
|
|
||||||
@Suppress("ModifierNaming")
|
@Suppress("ModifierNaming")
|
||||||
@Composable fun TypingText(text: AnnotatedString, textModifier: Modifier = Modifier) {
|
@Composable
|
||||||
|
fun TypingText(text: AnnotatedString, textModifier: Modifier = Modifier) {
|
||||||
Text(
|
Text(
|
||||||
modifier = textModifier,
|
modifier = textModifier,
|
||||||
text = text,
|
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
|
// Display the typing notification space when either a typing notification needs to be displayed or a previous one already was
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
modifier = modifier.fillMaxWidth().padding(vertical = 2.dp),
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
visible = displayNotifications || state.reserveSpace,
|
visible = displayNotifications || state.reserveSpace,
|
||||||
enter = fadeIn() + expandVertically(),
|
enter = fadeIn() + expandVertically(),
|
||||||
exit = fadeOut() + shrinkVertically(),
|
exit = fadeOut() + shrinkVertically(),
|
||||||
|
|
@ -95,7 +97,7 @@ fun TypingNotificationView(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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
|
// Remember the last value to avoid empty typing messages while animating
|
||||||
var result by remember { mutableStateOf(AnnotatedString("")) }
|
var result by remember { mutableStateOf(AnnotatedString("")) }
|
||||||
if (typingMembers.isNotEmpty()) {
|
if (typingMembers.isNotEmpty()) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -7,17 +7,25 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.utils
|
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.EMOJI
|
||||||
import com.sigpwned.emoji4j.core.Grapheme.Type.PICTOGRAPHIC
|
import com.sigpwned.emoji4j.core.Grapheme.Type.PICTOGRAPHIC
|
||||||
import com.sigpwned.emoji4j.core.GraphemeMatchResult
|
import com.sigpwned.emoji4j.core.GraphemeMatchResult
|
||||||
import com.sigpwned.emoji4j.core.GraphemeMatcher
|
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".
|
* Returns true if the string consists exclusively of "emoji or pictographic graphemes".
|
||||||
*/
|
*/
|
||||||
|
@Composable
|
||||||
fun String.containsOnlyEmojis(): Boolean {
|
fun String.containsOnlyEmojis(): Boolean {
|
||||||
|
if (LocalInspectionMode.current) return this == AN_EMOJI_ONLY_TEXT
|
||||||
if (isEmpty()) return false
|
if (isEmpty()) return false
|
||||||
|
return containsOnlyEmojisInternal()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.containsOnlyEmojisInternal(): Boolean {
|
||||||
val matcher = GraphemeMatcher(this)
|
val matcher = GraphemeMatcher(this)
|
||||||
var m: GraphemeMatchResult? = null
|
var m: GraphemeMatchResult? = null
|
||||||
var contiguous = true
|
var contiguous = true
|
||||||
|
|
|
||||||
|
|
@ -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.ActionListState
|
||||||
import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter
|
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.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.fixtures.aMessageEvent
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||||
|
|
@ -1026,6 +1027,7 @@ class MessagesPresenterTest {
|
||||||
customReactionPresenter = { aCustomReactionState() },
|
customReactionPresenter = { aCustomReactionState() },
|
||||||
reactionSummaryPresenter = { aReactionSummaryState() },
|
reactionSummaryPresenter = { aReactionSummaryState() },
|
||||||
readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() },
|
readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() },
|
||||||
|
identityChangeStatePresenter = { anIdentityChangeState() },
|
||||||
pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
|
pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
|
||||||
networkMonitor = FakeNetworkMonitor(),
|
networkMonitor = FakeNetworkMonitor(),
|
||||||
snackbarDispatcher = SnackbarDispatcher(),
|
snackbarDispatcher = SnackbarDispatcher(),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.A_USER_ID_4
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
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.api.store.SessionPreferencesStore
|
||||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
|
@ -49,7 +50,6 @@ class TypingNotificationPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - typing notification disabled`() = runTest {
|
fun `present - typing notification disabled`() = runTest {
|
||||||
val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2)
|
|
||||||
val room = FakeMatrixRoom()
|
val room = FakeMatrixRoom()
|
||||||
val sessionPreferencesStore = InMemorySessionPreferencesStore(
|
val sessionPreferencesStore = InMemorySessionPreferencesStore(
|
||||||
isRenderTypingNotificationsEnabled = false
|
isRenderTypingNotificationsEnabled = false
|
||||||
|
|
@ -73,7 +73,11 @@ class TypingNotificationPresenterTest {
|
||||||
val oneMemberTypingState = awaitItem()
|
val oneMemberTypingState = awaitItem()
|
||||||
assertThat(oneMemberTypingState.renderTypingNotifications).isTrue()
|
assertThat(oneMemberTypingState.renderTypingNotifications).isTrue()
|
||||||
assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1)
|
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
|
// Preferences changes again
|
||||||
sessionPreferencesStore.setRenderTypingNotifications(false)
|
sessionPreferencesStore.setRenderTypingNotifications(false)
|
||||||
skipItems(2)
|
skipItems(2)
|
||||||
|
|
@ -85,7 +89,6 @@ class TypingNotificationPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - state is updated when a member is typing, member is not known`() = runTest {
|
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 room = FakeMatrixRoom()
|
||||||
val presenter = createPresenter(matrixRoom = room)
|
val presenter = createPresenter(matrixRoom = room)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
|
@ -96,7 +99,11 @@ class TypingNotificationPresenterTest {
|
||||||
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
|
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
|
||||||
val oneMemberTypingState = awaitItem()
|
val oneMemberTypingState = awaitItem()
|
||||||
assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1)
|
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
|
// User stops typing
|
||||||
room.givenRoomTypingMembers(emptyList())
|
room.givenRoomTypingMembers(emptyList())
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
|
@ -129,7 +136,11 @@ class TypingNotificationPresenterTest {
|
||||||
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
|
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
|
||||||
val oneMemberTypingState = awaitItem()
|
val oneMemberTypingState = awaitItem()
|
||||||
assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1)
|
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
|
// User stops typing
|
||||||
room.givenRoomTypingMembers(emptyList())
|
room.givenRoomTypingMembers(emptyList())
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
|
@ -140,7 +151,6 @@ class TypingNotificationPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - state is updated when a member is typing, member is not known, then known`() = runTest {
|
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 aKnownRoomMember = createKnownRoomMember(A_USER_ID_2)
|
||||||
val room = FakeMatrixRoom()
|
val room = FakeMatrixRoom()
|
||||||
val presenter = createPresenter(matrixRoom = room)
|
val presenter = createPresenter(matrixRoom = room)
|
||||||
|
|
@ -152,7 +162,11 @@ class TypingNotificationPresenterTest {
|
||||||
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
|
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
|
||||||
val oneMemberTypingState = awaitItem()
|
val oneMemberTypingState = awaitItem()
|
||||||
assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1)
|
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
|
// User is getting known
|
||||||
room.givenRoomMembersState(
|
room.givenRoomMembersState(
|
||||||
MatrixRoomMembersState.Ready(
|
MatrixRoomMembersState.Ready(
|
||||||
|
|
@ -161,7 +175,11 @@ class TypingNotificationPresenterTest {
|
||||||
)
|
)
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val finalState = awaitItem()
|
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,
|
sessionPreferencesStore = sessionPreferencesStore,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun createDefaultRoomMember(
|
|
||||||
userId: UserId,
|
|
||||||
) = aTypingRoomMember(
|
|
||||||
userId = userId,
|
|
||||||
displayName = null,
|
|
||||||
isNameAmbiguous = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun createKnownRoomMember(
|
private fun createKnownRoomMember(
|
||||||
userId: UserId,
|
userId: UserId,
|
||||||
) = aTypingRoomMember(
|
) = aRoomMember(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
displayName = "Alice Doe",
|
displayName = "Alice Doe",
|
||||||
isNameAmbiguous = true,
|
isNameAmbiguous = true,
|
||||||
|
|
|
||||||
|
|
@ -8,29 +8,30 @@
|
||||||
package io.element.android.features.messages.impl.utils
|
package io.element.android.features.messages.impl.utils
|
||||||
|
|
||||||
import org.junit.Assert
|
import org.junit.Assert
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class EmojiTest {
|
class EmojiTest {
|
||||||
@Test
|
@Test
|
||||||
fun validEmojis() {
|
fun validEmojis() {
|
||||||
// Simple single/multiple single-codepoint emojis per string
|
// Simple single/multiple single-codepoint emojis per string
|
||||||
Assert.assertTrue("👍".containsOnlyEmojis())
|
assertTrue("👍".containsOnlyEmojisInternal())
|
||||||
Assert.assertTrue("😀".containsOnlyEmojis())
|
assertTrue("😀".containsOnlyEmojisInternal())
|
||||||
Assert.assertTrue("🙂🙁".containsOnlyEmojis())
|
assertTrue("🙂🙁".containsOnlyEmojisInternal())
|
||||||
Assert.assertTrue("👁❤️🍝".containsOnlyEmojis()) // 👁 is a pictographic
|
assertTrue("👁❤️🍝".containsOnlyEmojisInternal()) // 👁 is a pictographic
|
||||||
Assert.assertTrue("👨👩👦1️⃣🚀👳🏾♂️🪩".containsOnlyEmojis())
|
assertTrue("👨👩👦1️⃣🚀👳🏾♂️🪩".containsOnlyEmojisInternal())
|
||||||
Assert.assertTrue("🌍🌎🌏".containsOnlyEmojis())
|
assertTrue("🌍🌎🌏".containsOnlyEmojisInternal())
|
||||||
|
|
||||||
// Awkward multi-codepoint graphemes
|
// Awkward multi-codepoint graphemes
|
||||||
Assert.assertTrue("🧑🧑🧒🧒".containsOnlyEmojis())
|
assertTrue("🧑🧑🧒🧒".containsOnlyEmojisInternal())
|
||||||
Assert.assertTrue("🏴☠".containsOnlyEmojis())
|
assertTrue("🏴☠".containsOnlyEmojisInternal())
|
||||||
Assert.assertTrue("👩🏿🔧".containsOnlyEmojis())
|
assertTrue("👩🏿🔧".containsOnlyEmojisInternal())
|
||||||
|
|
||||||
Assert.assertFalse("".containsOnlyEmojis())
|
Assert.assertFalse("".containsOnlyEmojisInternal())
|
||||||
Assert.assertFalse(" ".containsOnlyEmojis())
|
Assert.assertFalse(" ".containsOnlyEmojisInternal())
|
||||||
Assert.assertFalse("🙂 🙁".containsOnlyEmojis())
|
Assert.assertFalse("🙂 🙁".containsOnlyEmojisInternal())
|
||||||
Assert.assertFalse(" 🙂 🙁 ".containsOnlyEmojis())
|
Assert.assertFalse(" 🙂 🙁 ".containsOnlyEmojisInternal())
|
||||||
Assert.assertFalse("Hello".containsOnlyEmojis())
|
Assert.assertFalse("Hello".containsOnlyEmojisInternal())
|
||||||
Assert.assertFalse("Hello 👋".containsOnlyEmojis())
|
Assert.assertFalse("Hello 👋".containsOnlyEmojisInternal())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import com.bumble.appyx.core.plugin.plugins
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
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
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
|
||||||
@ContributesNode(SessionScope::class)
|
@ContributesNode(SessionScope::class)
|
||||||
|
|
@ -59,7 +59,7 @@ class SecureBackupRootNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onLearnMoreClick(uriHandler: UriHandler) {
|
private fun onLearnMoreClick(uriHandler: UriHandler) {
|
||||||
uriHandler.openUri(SecureBackupConfig.LEARN_MORE_URL)
|
uriHandler.openUri(LearnMoreConfig.SECURE_BACKUP_URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import android.net.Uri
|
||||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||||
import androidx.browser.customtabs.CustomTabsIntent
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
import androidx.browser.customtabs.CustomTabsSession
|
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.
|
* 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))
|
.launchUrl(this, Uri.parse(url))
|
||||||
} catch (activityNotFoundException: ActivityNotFoundException) {
|
} catch (activityNotFoundException: ActivityNotFoundException) {
|
||||||
// TODO context.toast(R.string.error_no_external_application_found)
|
openUrlInExternalApp(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,8 @@ enum class AvatarSize(val dp: Dp) {
|
||||||
TimelineSender(32.dp),
|
TimelineSender(32.dp),
|
||||||
TimelineReadReceipt(16.dp),
|
TimelineReadReceipt(16.dp),
|
||||||
|
|
||||||
|
ComposerAlert(32.dp),
|
||||||
|
|
||||||
ReadReceiptList(32.dp),
|
ReadReceiptList(32.dp),
|
||||||
|
|
||||||
MessageActionSender(32.dp),
|
MessageActionSender(32.dp),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.api.encryption
|
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.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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.
|
* Starts the identity reset process. This will return a handle that can be used to reset the identity.
|
||||||
*/
|
*/
|
||||||
suspend fun startIdentityReset(): Result<IdentityResetHandle?>
|
suspend fun startIdentityReset(): Result<IdentityResetHandle?>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remember this identity, ensuring it does not result in a pin violation.
|
||||||
|
*/
|
||||||
|
suspend fun pinUserIdentity(userId: UserId): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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.TransactionId
|
||||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
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.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.AudioInfo
|
||||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||||
|
|
@ -52,6 +53,7 @@ interface MatrixRoom : Closeable {
|
||||||
|
|
||||||
val roomInfoFlow: Flow<MatrixRoomInfo>
|
val roomInfoFlow: Flow<MatrixRoomInfo>
|
||||||
val roomTypingMembersFlow: Flow<List<UserId>>
|
val roomTypingMembersFlow: Flow<List<UserId>>
|
||||||
|
val identityStateChangesFlow: Flow<List<IdentityStateChange>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A one-to-one is a room with exactly 2 members.
|
* A one-to-one is a room with exactly 2 members.
|
||||||
|
|
|
||||||
|
|
@ -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.flatMap
|
||||||
import io.element.android.libraries.core.extensions.mapFailure
|
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.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.BackupState
|
||||||
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
||||||
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
|
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
|
||||||
|
|
@ -202,4 +203,9 @@ internal class RustEncryptionService(
|
||||||
RustIdentityResetHandleFactory.create(sessionId, handle)
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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.TransactionId
|
||||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
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.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.AudioInfo
|
||||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
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.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
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.draft.into
|
||||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
|
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
|
||||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
|
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.onEach
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
|
||||||
import org.matrix.rustcomponents.sdk.RoomInfo
|
import org.matrix.rustcomponents.sdk.RoomInfo
|
||||||
import org.matrix.rustcomponents.sdk.RoomInfoListener
|
import org.matrix.rustcomponents.sdk.RoomInfoListener
|
||||||
import org.matrix.rustcomponents.sdk.RoomListItem
|
import org.matrix.rustcomponents.sdk.RoomListItem
|
||||||
|
|
@ -82,6 +85,7 @@ import timber.log.Timber
|
||||||
import uniffi.matrix_sdk.RoomPowerLevelChanges
|
import uniffi.matrix_sdk.RoomPowerLevelChanges
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
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.Room as InnerRoom
|
||||||
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
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...
|
// Create a dispatcher for all room methods...
|
||||||
private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32)
|
private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.test.encryption
|
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.BackupState
|
||||||
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
||||||
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
|
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
|
||||||
|
|
@ -21,6 +22,7 @@ import kotlinx.coroutines.flow.flowOf
|
||||||
|
|
||||||
class FakeEncryptionService(
|
class FakeEncryptionService(
|
||||||
var startIdentityResetLambda: () -> Result<IdentityResetHandle?> = { lambdaError() },
|
var startIdentityResetLambda: () -> Result<IdentityResetHandle?> = { lambdaError() },
|
||||||
|
private val pinUserIdentityResult: (UserId) -> Result<Unit> = { lambdaError() },
|
||||||
) : EncryptionService {
|
) : EncryptionService {
|
||||||
private var disableRecoveryFailure: Exception? = null
|
private var disableRecoveryFailure: Exception? = null
|
||||||
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
|
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
|
||||||
|
|
@ -117,6 +119,10 @@ class FakeEncryptionService(
|
||||||
return startIdentityResetLambda()
|
return startIdentityResetLambda()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun pinUserIdentity(userId: UserId): Result<Unit> {
|
||||||
|
return pinUserIdentityResult(userId)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val FAKE_RECOVERY_KEY = "fake"
|
const val FAKE_RECOVERY_KEY = "fake"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.TransactionId
|
||||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
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.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.AudioInfo
|
||||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||||
|
|
@ -137,7 +138,7 @@ class FakeMatrixRoom(
|
||||||
private val subscribeToSyncLambda: () -> Unit = { lambdaError() },
|
private val subscribeToSyncLambda: () -> Unit = { lambdaError() },
|
||||||
private val ignoreDeviceTrustAndResendResult: (Map<UserId, List<DeviceId>>, TransactionId) -> Result<Unit> = { _, _ -> lambdaError() },
|
private val ignoreDeviceTrustAndResendResult: (Map<UserId, List<DeviceId>>, TransactionId) -> Result<Unit> = { _, _ -> lambdaError() },
|
||||||
private val withdrawVerificationAndResendResult: (List<UserId>, TransactionId) -> Result<Unit> = { _, _ -> lambdaError() },
|
private val withdrawVerificationAndResendResult: (List<UserId>, TransactionId) -> Result<Unit> = { _, _ -> lambdaError() },
|
||||||
) : MatrixRoom {
|
) : MatrixRoom {
|
||||||
private val _roomInfoFlow: MutableSharedFlow<MatrixRoomInfo> = MutableSharedFlow(replay = 1)
|
private val _roomInfoFlow: MutableSharedFlow<MatrixRoomInfo> = MutableSharedFlow(replay = 1)
|
||||||
override val roomInfoFlow: Flow<MatrixRoomInfo> = _roomInfoFlow
|
override val roomInfoFlow: Flow<MatrixRoomInfo> = _roomInfoFlow
|
||||||
|
|
||||||
|
|
@ -152,6 +153,13 @@ class FakeMatrixRoom(
|
||||||
_roomTypingMembersFlow.tryEmit(typingMembers)
|
_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 membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
|
||||||
|
|
||||||
override val roomNotificationSettingsStateFlow: MutableStateFlow<MatrixRoomNotificationSettingsState> =
|
override val roomNotificationSettingsStateFlow: MutableStateFlow<MatrixRoomNotificationSettingsState> =
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ class KonsistPreviewTest {
|
||||||
"MessageComposerViewVoicePreview",
|
"MessageComposerViewVoicePreview",
|
||||||
"MessagesReactionButtonAddPreview",
|
"MessagesReactionButtonAddPreview",
|
||||||
"MessagesReactionButtonExtraPreview",
|
"MessagesReactionButtonExtraPreview",
|
||||||
|
"MessagesViewWithIdentityChangePreview",
|
||||||
"MessagesViewWithTypingPreview",
|
"MessagesViewWithTypingPreview",
|
||||||
"PageTitleWithIconFullPreview",
|
"PageTitleWithIconFullPreview",
|
||||||
"PageTitleWithIconMinimalPreview",
|
"PageTitleWithIconMinimalPreview",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650
|
||||||
|
size 3642
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:24481773bfccc5eb1ebf3f9955cdc77e8b3b5130d4fa56f96df732e3627ea3c6
|
||||||
|
size 21018
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd
|
||||||
|
size 3659
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1421adb601d9a8050a5ed1b60aba8a05b8eab61aaf18d3936226efe891acd8b6
|
||||||
|
size 23880
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b79329ddb864ea2100974330facffd3a50d1cb60935ec6079a56760fbe8f57e7
|
||||||
|
size 54972
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8d37a9ffee50f8e9ea0c9fd50c61310969c4fbaa99cf858481b3f42f8db4467d
|
||||||
|
size 61102
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ec81ab9e31dd4a2aad6ff8ac92a1bcabbf7f807c8e6ca8b91873ed6706f8af05
|
||||||
|
size 55396
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d0952d9cf3812ca419e144936faf523e0b865a22a61d2a55e07f089d9e6e9009
|
||||||
|
size 64824
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e631689b398ff2b91560c753043a9c7b4b25be7b3fdc2f3a3a0f00e2bf2db00d
|
||||||
|
size 20713
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1aec7a502a744d81e6e9f1dd9f5730b66c5ace3259b5e83a9304c69286806590
|
||||||
|
size 19999
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f74c2ee3d31418214fa50f2829eb71178a7500401a72c8f46d048925eba2d462
|
||||||
|
size 23389
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7c17e0c95c2e48e2ac0f4ce7a7cd97bf255a8d0e304146808ef1837e1c951e73
|
||||||
|
size 23177
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:1eb10a2627a2a7be8af030a305cbec963f6d3f03358d9acdfe1d58e852247759
|
oid sha256:88eac082691b8314a06f16820f7ace62321569c3fa633cb243ee9ba508dfb7bd
|
||||||
size 16058
|
size 15712
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8d294591f355f604bb101abd6b7f27fe4f6d884d9d561baf46fc45e6d8111fd6
|
oid sha256:fd4f7a9468c8db222fbc3631ad4bf7876d80bb31ef1d79292ad72d01f20546f2
|
||||||
size 15332
|
size 14951
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:96518c010196d80e8696ffcaea032468bb4d1edfbe6b1187286da488600363bf
|
oid sha256:fe30a0d96effe257973c893b6450a357d49f11e0f4743b2fdb16050fc15b3a8f
|
||||||
size 17891
|
size 17549
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:af4a8ea34761a5af0f14f979e9dc83680703a9ff8e5ce53ee0bcc4b36a39aa3d
|
oid sha256:1eb10a2627a2a7be8af030a305cbec963f6d3f03358d9acdfe1d58e852247759
|
||||||
size 19231
|
size 16058
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4a3808fc8a5a2160144e93508dbcb09b62e52e9f7f83cf4ec8d8e7a20c7cf554
|
oid sha256:8d294591f355f604bb101abd6b7f27fe4f6d884d9d561baf46fc45e6d8111fd6
|
||||||
size 18469
|
size 15332
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:9dd0232c7847d5c0bb1047cfc6be5d4d43d2cc2e9347aff70a793103b511ad98
|
oid sha256:96518c010196d80e8696ffcaea032468bb4d1edfbe6b1187286da488600363bf
|
||||||
size 21073
|
size 17891
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:1424c9540b87a819d8fd4135db963552794ffde39f5915b1bbbc0ec83d747bcf
|
oid sha256:af4a8ea34761a5af0f14f979e9dc83680703a9ff8e5ce53ee0bcc4b36a39aa3d
|
||||||
size 16595
|
size 19231
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:53d2bff076eb5a8e855b54f79cd179e57750e24a457b49a64aca9f176d34b294
|
oid sha256:4a3808fc8a5a2160144e93508dbcb09b62e52e9f7f83cf4ec8d8e7a20c7cf554
|
||||||
size 15348
|
size 18469
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d1570611e0a894a88644956558629113717217ce4b7a76c615264bc2fa776616
|
oid sha256:9dd0232c7847d5c0bb1047cfc6be5d4d43d2cc2e9347aff70a793103b511ad98
|
||||||
size 19763
|
size 21073
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:38cb5a55723aa8c0e8044bb732f8013d3cdc030797ef981d5cc867f0c70203bf
|
oid sha256:1424c9540b87a819d8fd4135db963552794ffde39f5915b1bbbc0ec83d747bcf
|
||||||
size 12923
|
size 16595
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f6458f921eebb7ceebbea2b8e6da2aa18071015e02ef68e4904e3a84cb460e5c
|
oid sha256:53d2bff076eb5a8e855b54f79cd179e57750e24a457b49a64aca9f176d34b294
|
||||||
size 12584
|
size 15348
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:68cb86e7966e17d3240de09ba69aa4af36e4b07c1b669b5003af8071d6eb1ee1
|
oid sha256:d1570611e0a894a88644956558629113717217ce4b7a76c615264bc2fa776616
|
||||||
size 13832
|
size 19763
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:115d72ff7fae6a8673dd1908ef749710042d04c4b37f6cb13ec647682412d22a
|
oid sha256:38cb5a55723aa8c0e8044bb732f8013d3cdc030797ef981d5cc867f0c70203bf
|
||||||
size 18643
|
size 12923
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:780cc6ffcae4754ae67fcc522a2149a1cd8b20cd1e1441d28c5441a2d0d760a3
|
oid sha256:f6458f921eebb7ceebbea2b8e6da2aa18071015e02ef68e4904e3a84cb460e5c
|
||||||
size 17006
|
size 12584
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8e196a6ccf55855e8cad09e9934e09c8564cad08b4dcbc418c4a2b8d467088c3
|
oid sha256:68cb86e7966e17d3240de09ba69aa4af36e4b07c1b669b5003af8071d6eb1ee1
|
||||||
size 22891
|
size 13832
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:5f826889a753d73bbeb71bd9706a21c476f93e448b2491160045392b6472b4da
|
oid sha256:115d72ff7fae6a8673dd1908ef749710042d04c4b37f6cb13ec647682412d22a
|
||||||
size 20976
|
size 18643
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:efa1c5a898ee842b444115e45eb57c1c1624d84448fea79436a7b153f97d0c73
|
oid sha256:780cc6ffcae4754ae67fcc522a2149a1cd8b20cd1e1441d28c5441a2d0d760a3
|
||||||
size 19356
|
size 17006
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:746b7181d7f9a3c25040624a2c20378b1eff1ce299840c6f4b4b9ca3f877bdfd
|
oid sha256:8e196a6ccf55855e8cad09e9934e09c8564cad08b4dcbc418c4a2b8d467088c3
|
||||||
size 24976
|
size 22891
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f3b7c704d2aa78d7ca48ea8a2c44e4038a543e3a42df9adb6170a9d8e8497335
|
oid sha256:5f826889a753d73bbeb71bd9706a21c476f93e448b2491160045392b6472b4da
|
||||||
size 16661
|
size 20976
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c403904d27f1b0c7b7964e8ddaf96b27df9f7e660c67fca72849a53591b8360d
|
oid sha256:efa1c5a898ee842b444115e45eb57c1c1624d84448fea79436a7b153f97d0c73
|
||||||
size 15912
|
size 19356
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:67a8223f107fa05f1c5d66efc85f68325b4bf835e371ad44553bd6d6edd4a201
|
oid sha256:746b7181d7f9a3c25040624a2c20378b1eff1ce299840c6f4b4b9ca3f877bdfd
|
||||||
size 18491
|
size 24976
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:76077b390e22e005eee695d0da5dc3835182214675d4e1b5aeb1f05cf5614ff6
|
oid sha256:f3b7c704d2aa78d7ca48ea8a2c44e4038a543e3a42df9adb6170a9d8e8497335
|
||||||
size 21544
|
size 16661
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:2a624a14ff7ca0fb65e89e69357eb551ee4fd4aab4cebbb38cfb017fdca8f832
|
oid sha256:c403904d27f1b0c7b7964e8ddaf96b27df9f7e660c67fca72849a53591b8360d
|
||||||
size 20702
|
size 15912
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:be8c830118af26d3d39a80d5dfa3a750e6e3830a30b066ca91b7ed305a2ee482
|
oid sha256:67a8223f107fa05f1c5d66efc85f68325b4bf835e371ad44553bd6d6edd4a201
|
||||||
size 23624
|
size 18491
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:631ae3b88cd0be00dfc4ceda6ecfd49f56526c2605c5c3c1f3975443a6a726b8
|
oid sha256:76077b390e22e005eee695d0da5dc3835182214675d4e1b5aeb1f05cf5614ff6
|
||||||
size 17310
|
size 21544
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c2105c726a015292a180feb55164181d331b9f162e47152d36d49d6ccbe6e3fc
|
oid sha256:2a624a14ff7ca0fb65e89e69357eb551ee4fd4aab4cebbb38cfb017fdca8f832
|
||||||
size 16460
|
size 20702
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:672f829a7227921b7179e91c1ee3ef58ff2b7dab7f131b687c4fe0b9862d2a2c
|
oid sha256:be8c830118af26d3d39a80d5dfa3a750e6e3830a30b066ca91b7ed305a2ee482
|
||||||
size 19436
|
size 23624
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e66799a7ec09998d9377fae8f6ff79c46cf8978a6c2a95c7fff7f21f55bd87f3
|
oid sha256:631ae3b88cd0be00dfc4ceda6ecfd49f56526c2605c5c3c1f3975443a6a726b8
|
||||||
size 20976
|
size 17310
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c2db8f5b2d57debf2dfc4dbfe807ebe8278d27994242761abb709119374c9786
|
oid sha256:c2105c726a015292a180feb55164181d331b9f162e47152d36d49d6ccbe6e3fc
|
||||||
size 18769
|
size 16460
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:2f3a7f0a4944f35fd726d5090eab08140b57332e6f7d8eb475fc6bf9ef37bcdf
|
oid sha256:672f829a7227921b7179e91c1ee3ef58ff2b7dab7f131b687c4fe0b9862d2a2c
|
||||||
size 26033
|
size 19436
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:ad1c15d26a5f2711585276388d3595a997249e336950937a57c7beecd4b6b984
|
oid sha256:e66799a7ec09998d9377fae8f6ff79c46cf8978a6c2a95c7fff7f21f55bd87f3
|
||||||
size 14956
|
size 20976
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:ec1f5a41d1039cc3f93011fc6d05420964cc0d5d53db393c47a6dd9916588602
|
oid sha256:c2db8f5b2d57debf2dfc4dbfe807ebe8278d27994242761abb709119374c9786
|
||||||
size 14211
|
size 18769
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e88e6991bca9f0c99c28b6126b10135d0ba8de1faa7bc6174e6f66bc11b2ce03
|
oid sha256:2f3a7f0a4944f35fd726d5090eab08140b57332e6f7d8eb475fc6bf9ef37bcdf
|
||||||
size 16794
|
size 26033
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ad1c15d26a5f2711585276388d3595a997249e336950937a57c7beecd4b6b984
|
||||||
|
size 14956
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ec1f5a41d1039cc3f93011fc6d05420964cc0d5d53db393c47a6dd9916588602
|
||||||
|
size 14211
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e88e6991bca9f0c99c28b6126b10135d0ba8de1faa7bc6174e6f66bc11b2ce03
|
||||||
|
size 16794
|
||||||
Loading…
Add table
Add a link
Reference in a new issue