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:
@@ -7,6 +7,7 @@
|
||||
|
||||
package io.element.android.appconfig
|
||||
|
||||
object SecureBackupConfig {
|
||||
const val LEARN_MORE_URL: String = "https://element.io/help#encryption5"
|
||||
object LearnMoreConfig {
|
||||
const val SECURE_BACKUP_URL: String = "https://element.io/help#encryption5"
|
||||
const val IDENTITY_CHANGE_URL: String = "https://element.io/help#encryption18"
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
package io.element.android.features.messages.impl
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
@@ -26,19 +27,19 @@ import com.bumble.appyx.core.plugin.plugins
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
|
||||
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
|
||||
import io.element.android.libraries.androidutils.system.toast
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -63,8 +64,6 @@ class MessagesNode @AssistedInject constructor(
|
||||
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
|
||||
private val mediaPlayer: MediaPlayer,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
@ApplicationContext
|
||||
private val context: Context,
|
||||
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||
private val presenter = presenterFactory.create(this)
|
||||
private val callbacks = plugins<Callback>()
|
||||
@@ -124,7 +123,8 @@ class MessagesNode @AssistedInject constructor(
|
||||
}
|
||||
|
||||
private fun onLinkClick(
|
||||
context: Context,
|
||||
activity: Activity,
|
||||
darkTheme: Boolean,
|
||||
url: String,
|
||||
eventSink: (TimelineEvents) -> Unit,
|
||||
) {
|
||||
@@ -135,16 +135,20 @@ class MessagesNode @AssistedInject constructor(
|
||||
callbacks.forEach { it.onUserDataClick(permalink.userId) }
|
||||
}
|
||||
is PermalinkData.RoomLink -> {
|
||||
handleRoomLinkClick(permalink, eventSink)
|
||||
handleRoomLinkClick(activity, permalink, eventSink)
|
||||
}
|
||||
is PermalinkData.FallbackLink,
|
||||
is PermalinkData.RoomEmailInviteLink -> {
|
||||
context.openUrlInExternalApp(url)
|
||||
activity.openUrlInChromeCustomTab(null, darkTheme, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRoomLinkClick(roomLink: PermalinkData.RoomLink, eventSink: (TimelineEvents) -> Unit) {
|
||||
private fun handleRoomLinkClick(
|
||||
context: Context,
|
||||
roomLink: PermalinkData.RoomLink,
|
||||
eventSink: (TimelineEvents) -> Unit,
|
||||
) {
|
||||
if (room.matches(roomLink.roomIdOrAlias)) {
|
||||
val eventId = roomLink.eventId
|
||||
if (eventId != null) {
|
||||
@@ -192,7 +196,8 @@ class MessagesNode @AssistedInject constructor(
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val context = LocalContext.current
|
||||
val activity = LocalContext.current as Activity
|
||||
val isDark = ElementTheme.isLightTheme.not()
|
||||
CompositionLocalProvider(
|
||||
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
|
||||
) {
|
||||
@@ -210,7 +215,7 @@ class MessagesNode @AssistedInject constructor(
|
||||
onEventClick = this::onEventClick,
|
||||
onPreviewAttachments = this::onPreviewAttachments,
|
||||
onUserDataClick = this::onUserDataClick,
|
||||
onLinkClick = { onLinkClick(context, it, state.timelineState.eventSink) },
|
||||
onLinkClick = { url -> onLinkClick(activity, isDark, url, state.timelineState.eventSink) },
|
||||
onSendLocationClick = this::onSendLocationClick,
|
||||
onCreatePollClick = this::onCreatePollClick,
|
||||
onJoinCallClick = this::onJoinCallClick,
|
||||
|
||||
@@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
|
||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
||||
@@ -91,6 +92,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
private val voiceMessageComposerPresenter: Presenter<VoiceMessageComposerState>,
|
||||
timelinePresenterFactory: TimelinePresenter.Factory,
|
||||
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
|
||||
private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
|
||||
private val actionListPresenterFactory: ActionListPresenter.Factory,
|
||||
private val customReactionPresenter: Presenter<CustomReactionState>,
|
||||
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
|
||||
@@ -125,6 +127,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
val voiceMessageComposerState = voiceMessageComposerPresenter.present()
|
||||
val timelineState = timelinePresenter.present()
|
||||
val timelineProtectionState = timelineProtectionPresenter.present()
|
||||
val identityChangeState = identityChangeStatePresenter.present()
|
||||
val actionListState = actionListPresenter.present()
|
||||
val customReactionState = customReactionPresenter.present()
|
||||
val reactionSummaryState = reactionSummaryPresenter.present()
|
||||
@@ -217,6 +220,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
voiceMessageComposerState = voiceMessageComposerState,
|
||||
timelineState = timelineState,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
identityChangeState = identityChangeState,
|
||||
actionListState = actionListState,
|
||||
customReactionState = customReactionState,
|
||||
reactionSummaryState = reactionSummaryState,
|
||||
|
||||
@@ -9,6 +9,7 @@ package io.element.android.features.messages.impl
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||
@@ -34,6 +35,7 @@ data class MessagesState(
|
||||
val voiceMessageComposerState: VoiceMessageComposerState,
|
||||
val timelineState: TimelineState,
|
||||
val timelineProtectionState: TimelineProtectionState,
|
||||
val identityChangeState: IdentityChangeState,
|
||||
val actionListState: ActionListState,
|
||||
val customReactionState: CustomReactionState,
|
||||
val reactionSummaryState: ReactionSummaryState,
|
||||
|
||||
@@ -10,6 +10,8 @@ package io.element.android.features.messages.impl
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||
import io.element.android.features.messages.impl.actionlist.anActionListState
|
||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
|
||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
||||
@@ -106,6 +108,7 @@ fun aMessagesState(
|
||||
focusedEventIndex = 2,
|
||||
),
|
||||
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
|
||||
identityChangeState: IdentityChangeState = anIdentityChangeState(),
|
||||
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
|
||||
actionListState: ActionListState = anActionListState(),
|
||||
customReactionState: CustomReactionState = aCustomReactionState(),
|
||||
@@ -125,6 +128,7 @@ fun aMessagesState(
|
||||
composerState = composerState,
|
||||
voiceMessageComposerState = voiceMessageComposerState,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
identityChangeState = identityChangeState,
|
||||
timelineState = timelineState,
|
||||
readReceiptBottomSheetState = readReceiptBottomSheetState,
|
||||
actionListState = actionListState,
|
||||
|
||||
@@ -57,6 +57,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListView
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView
|
||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
|
||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
@@ -103,6 +104,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import timber.log.Timber
|
||||
@@ -415,6 +417,7 @@ private fun MessagesViewContent(
|
||||
MessagesViewComposerBottomSheetContents(
|
||||
subcomposing = subcomposing,
|
||||
state = state,
|
||||
onLinkClick = onLinkClick,
|
||||
)
|
||||
},
|
||||
sheetContentKey = sheetResizeContentKey.intValue,
|
||||
@@ -428,6 +431,7 @@ private fun MessagesViewContent(
|
||||
private fun MessagesViewComposerBottomSheetContents(
|
||||
subcomposing: Boolean,
|
||||
state: MessagesState,
|
||||
onLinkClick: (String) -> Unit,
|
||||
) {
|
||||
if (state.userEventPermissions.canSendMessage) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -448,6 +452,14 @@ private fun MessagesViewComposerBottomSheetContents(
|
||||
state.composerState.eventSink(MessageComposerEvents.InsertSuggestion(it))
|
||||
}
|
||||
)
|
||||
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
|
||||
if (state.composerState.suggestions.isEmpty() &&
|
||||
state.composerState.textEditorState is TextEditorState.Markdown) {
|
||||
IdentityChangeStateView(
|
||||
state = state.identityChangeState,
|
||||
onLinkClick = onLinkClick,
|
||||
)
|
||||
}
|
||||
MessageComposerView(
|
||||
state = state.composerState,
|
||||
voiceMessageState = state.voiceMessageComposerState,
|
||||
|
||||
@@ -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 dagger.Binds
|
||||
import dagger.Module
|
||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||
@@ -60,4 +62,7 @@ interface MessagesModule {
|
||||
|
||||
@Binds
|
||||
fun bindReadReceiptBottomSheetPresenter(presenter: ReadReceiptBottomSheetPresenter): Presenter<ReadReceiptBottomSheetState>
|
||||
|
||||
@Binds
|
||||
fun bindIdentityChangeStatePresenter(presenter: IdentityChangeStatePresenter): Presenter<IdentityChangeState>
|
||||
}
|
||||
|
||||
@@ -33,10 +33,12 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
||||
aTimelineItemTextContent(),
|
||||
aTimelineItemUnknownContent(),
|
||||
aTimelineItemTextContent().copy(isEdited = true),
|
||||
aTimelineItemTextContent(body = "😁")
|
||||
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT)
|
||||
)
|
||||
}
|
||||
|
||||
const val AN_EMOJI_ONLY_TEXT = "😁"
|
||||
|
||||
class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineItemTextBasedContent> {
|
||||
private fun buildSpanned(text: String) = buildSpannedString {
|
||||
inSpans(StyleSpan(Typeface.BOLD)) {
|
||||
|
||||
@@ -9,21 +9,22 @@ package io.element.android.features.messages.impl.typing
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.ProduceStateScope
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
@@ -36,64 +37,56 @@ class TypingNotificationPresenter @Inject constructor(
|
||||
) : Presenter<TypingNotificationState> {
|
||||
@Composable
|
||||
override fun present(): TypingNotificationState {
|
||||
val typingMembersState = remember { mutableStateOf(emptyList<RoomMember>()) }
|
||||
val renderTypingNotifications by sessionPreferencesStore.isRenderTypingNotificationsEnabled().collectAsState(initial = true)
|
||||
|
||||
LaunchedEffect(renderTypingNotifications) {
|
||||
val typingMembersState by produceState(initialValue = persistentListOf(), key1 = renderTypingNotifications) {
|
||||
if (renderTypingNotifications) {
|
||||
observeRoomTypingMembers(typingMembersState)
|
||||
observeRoomTypingMembers()
|
||||
} else {
|
||||
typingMembersState.value = emptyList()
|
||||
value = persistentListOf<TypingRoomMember>()
|
||||
}
|
||||
}
|
||||
|
||||
// This will keep the space reserved for the typing notifications after the first one is displayed
|
||||
var reserveSpace by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(renderTypingNotifications, typingMembersState.value) {
|
||||
if (renderTypingNotifications && typingMembersState.value.isNotEmpty()) {
|
||||
LaunchedEffect(renderTypingNotifications, typingMembersState) {
|
||||
if (renderTypingNotifications && typingMembersState.isNotEmpty()) {
|
||||
reserveSpace = true
|
||||
}
|
||||
}
|
||||
|
||||
return TypingNotificationState(
|
||||
renderTypingNotifications = renderTypingNotifications,
|
||||
typingMembers = typingMembersState.value.toImmutableList(),
|
||||
typingMembers = typingMembersState,
|
||||
reserveSpace = reserveSpace,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.observeRoomTypingMembers(typingMembersState: MutableState<List<RoomMember>>) {
|
||||
private fun ProduceStateScope<ImmutableList<TypingRoomMember>>.observeRoomTypingMembers() {
|
||||
combine(room.roomTypingMembersFlow, room.membersStateFlow) { typingMembers, membersState ->
|
||||
typingMembers
|
||||
.map { userId ->
|
||||
membersState.roomMembers()
|
||||
?.firstOrNull { roomMember -> roomMember.userId == userId }
|
||||
?.toTypingRoomMember()
|
||||
?: createDefaultRoomMemberForTyping(userId)
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.onEach { members ->
|
||||
typingMembersState.value = members
|
||||
value = members.toImmutableList()
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a default [RoomMember] for typing events.
|
||||
* In this case, only the userId will be used for rendering, other fields are not used, but keep them
|
||||
* as close as possible to the actual data.
|
||||
*/
|
||||
private fun createDefaultRoomMemberForTyping(userId: UserId): RoomMember {
|
||||
return RoomMember(
|
||||
userId = userId,
|
||||
displayName = null,
|
||||
avatarUrl = null,
|
||||
membership = RoomMembershipState.JOIN,
|
||||
isNameAmbiguous = false,
|
||||
powerLevel = 0,
|
||||
normalizedPowerLevel = 0,
|
||||
isIgnored = false,
|
||||
role = RoomMember.Role.USER,
|
||||
private fun RoomMember.toTypingRoomMember(): TypingRoomMember {
|
||||
return TypingRoomMember(
|
||||
disambiguatedDisplayName = disambiguatedDisplayName,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createDefaultRoomMemberForTyping(userId: UserId): TypingRoomMember {
|
||||
return TypingRoomMember(
|
||||
disambiguatedDisplayName = userId.value,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
package io.element.android.features.messages.impl.typing
|
||||
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
@@ -17,7 +16,7 @@ data class TypingNotificationState(
|
||||
/** Whether to render the typing notifications based on the user's preferences. */
|
||||
val renderTypingNotifications: Boolean,
|
||||
/** The room members currently typing. */
|
||||
val typingMembers: ImmutableList<RoomMember>,
|
||||
val typingMembers: ImmutableList<TypingRoomMember>,
|
||||
/** Whether to reserve space for the typing notifications at the bottom of the timeline. */
|
||||
val reserveSpace: Boolean,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificationState> {
|
||||
@@ -24,39 +21,39 @@ class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificat
|
||||
),
|
||||
aTypingNotificationState(
|
||||
typingMembers = listOf(
|
||||
aTypingRoomMember(displayName = "Alice"),
|
||||
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
|
||||
),
|
||||
),
|
||||
aTypingNotificationState(
|
||||
typingMembers = listOf(
|
||||
aTypingRoomMember(displayName = "Alice", isNameAmbiguous = true),
|
||||
aTypingRoomMember(disambiguatedDisplayName = "Alice (@alice:example.com)"),
|
||||
),
|
||||
),
|
||||
aTypingNotificationState(
|
||||
typingMembers = listOf(
|
||||
aTypingRoomMember(displayName = "Alice"),
|
||||
aTypingRoomMember(displayName = "Bob"),
|
||||
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
|
||||
aTypingRoomMember(disambiguatedDisplayName = "Bob"),
|
||||
),
|
||||
),
|
||||
aTypingNotificationState(
|
||||
typingMembers = listOf(
|
||||
aTypingRoomMember(displayName = "Alice"),
|
||||
aTypingRoomMember(displayName = "Bob"),
|
||||
aTypingRoomMember(displayName = "Charlie"),
|
||||
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
|
||||
aTypingRoomMember(disambiguatedDisplayName = "Bob"),
|
||||
aTypingRoomMember(disambiguatedDisplayName = "Charlie"),
|
||||
),
|
||||
),
|
||||
aTypingNotificationState(
|
||||
typingMembers = listOf(
|
||||
aTypingRoomMember(displayName = "Alice"),
|
||||
aTypingRoomMember(displayName = "Bob"),
|
||||
aTypingRoomMember(displayName = "Charlie"),
|
||||
aTypingRoomMember(displayName = "Dan"),
|
||||
aTypingRoomMember(displayName = "Eve"),
|
||||
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
|
||||
aTypingRoomMember(disambiguatedDisplayName = "Bob"),
|
||||
aTypingRoomMember(disambiguatedDisplayName = "Charlie"),
|
||||
aTypingRoomMember(disambiguatedDisplayName = "Dan"),
|
||||
aTypingRoomMember(disambiguatedDisplayName = "Eve"),
|
||||
),
|
||||
),
|
||||
aTypingNotificationState(
|
||||
typingMembers = listOf(
|
||||
aTypingRoomMember(displayName = "Alice with a very long display name which means that it will be truncated"),
|
||||
aTypingRoomMember(disambiguatedDisplayName = "Alice with a very long display name which means that it will be truncated"),
|
||||
),
|
||||
),
|
||||
aTypingNotificationState(
|
||||
@@ -67,7 +64,7 @@ class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificat
|
||||
}
|
||||
|
||||
internal fun aTypingNotificationState(
|
||||
typingMembers: List<RoomMember> = emptyList(),
|
||||
typingMembers: List<TypingRoomMember> = emptyList(),
|
||||
reserveSpace: Boolean = false,
|
||||
) = TypingNotificationState(
|
||||
renderTypingNotifications = true,
|
||||
@@ -76,19 +73,7 @@ internal fun aTypingNotificationState(
|
||||
)
|
||||
|
||||
internal fun aTypingRoomMember(
|
||||
userId: UserId = UserId("@alice:example.com"),
|
||||
displayName: String? = null,
|
||||
isNameAmbiguous: Boolean = false,
|
||||
): RoomMember {
|
||||
return RoomMember(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
avatarUrl = null,
|
||||
membership = RoomMembershipState.JOIN,
|
||||
isNameAmbiguous = isNameAmbiguous,
|
||||
powerLevel = 0,
|
||||
normalizedPowerLevel = 0,
|
||||
isIgnored = false,
|
||||
role = RoomMember.Role.USER,
|
||||
)
|
||||
}
|
||||
disambiguatedDisplayName: String = "@alice:example.com",
|
||||
) = TypingRoomMember(
|
||||
disambiguatedDisplayName = disambiguatedDisplayName,
|
||||
)
|
||||
|
||||
@@ -41,7 +41,6 @@ import io.element.android.features.messages.impl.R
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Suppress("MultipleEmitters") // False positive
|
||||
@@ -53,7 +52,8 @@ fun TypingNotificationView(
|
||||
val displayNotifications = state.typingMembers.isNotEmpty() && state.renderTypingNotifications
|
||||
|
||||
@Suppress("ModifierNaming")
|
||||
@Composable fun TypingText(text: AnnotatedString, textModifier: Modifier = Modifier) {
|
||||
@Composable
|
||||
fun TypingText(text: AnnotatedString, textModifier: Modifier = Modifier) {
|
||||
Text(
|
||||
modifier = textModifier,
|
||||
text = text,
|
||||
@@ -66,7 +66,9 @@ fun TypingNotificationView(
|
||||
|
||||
// Display the typing notification space when either a typing notification needs to be displayed or a previous one already was
|
||||
AnimatedVisibility(
|
||||
modifier = modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp),
|
||||
visible = displayNotifications || state.reserveSpace,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
@@ -95,7 +97,7 @@ fun TypingNotificationView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun computeTypingNotificationText(typingMembers: ImmutableList<RoomMember>): AnnotatedString {
|
||||
private fun computeTypingNotificationText(typingMembers: ImmutableList<TypingRoomMember>): AnnotatedString {
|
||||
// Remember the last value to avoid empty typing messages while animating
|
||||
var result by remember { mutableStateOf(AnnotatedString("")) }
|
||||
if (typingMembers.isNotEmpty()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import com.sigpwned.emoji4j.core.Grapheme.Type.EMOJI
|
||||
import com.sigpwned.emoji4j.core.Grapheme.Type.PICTOGRAPHIC
|
||||
import com.sigpwned.emoji4j.core.GraphemeMatchResult
|
||||
import com.sigpwned.emoji4j.core.GraphemeMatcher
|
||||
import io.element.android.features.messages.impl.timeline.model.event.AN_EMOJI_ONLY_TEXT
|
||||
|
||||
/**
|
||||
* Returns true if the string consists exclusively of "emoji or pictographic graphemes".
|
||||
*/
|
||||
@Composable
|
||||
fun String.containsOnlyEmojis(): Boolean {
|
||||
if (LocalInspectionMode.current) return this == AN_EMOJI_ONLY_TEXT
|
||||
if (isEmpty()) return false
|
||||
return containsOnlyEmojisInternal()
|
||||
}
|
||||
|
||||
internal fun String.containsOnlyEmojisInternal(): Boolean {
|
||||
val matcher = GraphemeMatcher(this)
|
||||
var m: GraphemeMatchResult? = null
|
||||
var contiguous = true
|
||||
|
||||
@@ -16,6 +16,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||
import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
|
||||
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
@@ -1026,6 +1027,7 @@ class MessagesPresenterTest {
|
||||
customReactionPresenter = { aCustomReactionState() },
|
||||
reactionSummaryPresenter = { aReactionSummaryState() },
|
||||
readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() },
|
||||
identityChangeStatePresenter = { anIdentityChangeState() },
|
||||
pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
|
||||
networkMonitor = FakeNetworkMonitor(),
|
||||
snackbarDispatcher = SnackbarDispatcher(),
|
||||
|
||||
@@ -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.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
@@ -49,7 +50,6 @@ class TypingNotificationPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - typing notification disabled`() = runTest {
|
||||
val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2)
|
||||
val room = FakeMatrixRoom()
|
||||
val sessionPreferencesStore = InMemorySessionPreferencesStore(
|
||||
isRenderTypingNotificationsEnabled = false
|
||||
@@ -73,7 +73,11 @@ class TypingNotificationPresenterTest {
|
||||
val oneMemberTypingState = awaitItem()
|
||||
assertThat(oneMemberTypingState.renderTypingNotifications).isTrue()
|
||||
assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1)
|
||||
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember)
|
||||
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(
|
||||
TypingRoomMember(
|
||||
disambiguatedDisplayName = A_USER_ID_2.value,
|
||||
)
|
||||
)
|
||||
// Preferences changes again
|
||||
sessionPreferencesStore.setRenderTypingNotifications(false)
|
||||
skipItems(2)
|
||||
@@ -85,7 +89,6 @@ class TypingNotificationPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - state is updated when a member is typing, member is not known`() = runTest {
|
||||
val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2)
|
||||
val room = FakeMatrixRoom()
|
||||
val presenter = createPresenter(matrixRoom = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
@@ -96,7 +99,11 @@ class TypingNotificationPresenterTest {
|
||||
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
|
||||
val oneMemberTypingState = awaitItem()
|
||||
assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1)
|
||||
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember)
|
||||
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(
|
||||
TypingRoomMember(
|
||||
disambiguatedDisplayName = A_USER_ID_2.value,
|
||||
)
|
||||
)
|
||||
// User stops typing
|
||||
room.givenRoomTypingMembers(emptyList())
|
||||
skipItems(1)
|
||||
@@ -129,7 +136,11 @@ class TypingNotificationPresenterTest {
|
||||
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
|
||||
val oneMemberTypingState = awaitItem()
|
||||
assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1)
|
||||
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aKnownRoomMember)
|
||||
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(
|
||||
TypingRoomMember(
|
||||
disambiguatedDisplayName = "Alice Doe (@bob:server.org)",
|
||||
)
|
||||
)
|
||||
// User stops typing
|
||||
room.givenRoomTypingMembers(emptyList())
|
||||
skipItems(1)
|
||||
@@ -140,7 +151,6 @@ class TypingNotificationPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - state is updated when a member is typing, member is not known, then known`() = runTest {
|
||||
val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2)
|
||||
val aKnownRoomMember = createKnownRoomMember(A_USER_ID_2)
|
||||
val room = FakeMatrixRoom()
|
||||
val presenter = createPresenter(matrixRoom = room)
|
||||
@@ -152,7 +162,11 @@ class TypingNotificationPresenterTest {
|
||||
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
|
||||
val oneMemberTypingState = awaitItem()
|
||||
assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1)
|
||||
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember)
|
||||
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(
|
||||
TypingRoomMember(
|
||||
disambiguatedDisplayName = A_USER_ID_2.value,
|
||||
)
|
||||
)
|
||||
// User is getting known
|
||||
room.givenRoomMembersState(
|
||||
MatrixRoomMembersState.Ready(
|
||||
@@ -161,7 +175,11 @@ class TypingNotificationPresenterTest {
|
||||
)
|
||||
skipItems(1)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.typingMembers.first()).isEqualTo(aKnownRoomMember)
|
||||
assertThat(finalState.typingMembers.first()).isEqualTo(
|
||||
TypingRoomMember(
|
||||
disambiguatedDisplayName = "Alice Doe (@bob:server.org)",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,17 +220,9 @@ class TypingNotificationPresenterTest {
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
)
|
||||
|
||||
private fun createDefaultRoomMember(
|
||||
userId: UserId,
|
||||
) = aTypingRoomMember(
|
||||
userId = userId,
|
||||
displayName = null,
|
||||
isNameAmbiguous = false,
|
||||
)
|
||||
|
||||
private fun createKnownRoomMember(
|
||||
userId: UserId,
|
||||
) = aTypingRoomMember(
|
||||
) = aRoomMember(
|
||||
userId = userId,
|
||||
displayName = "Alice Doe",
|
||||
isNameAmbiguous = true,
|
||||
|
||||
@@ -8,29 +8,30 @@
|
||||
package io.element.android.features.messages.impl.utils
|
||||
|
||||
import org.junit.Assert
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class EmojiTest {
|
||||
@Test
|
||||
fun validEmojis() {
|
||||
// Simple single/multiple single-codepoint emojis per string
|
||||
Assert.assertTrue("👍".containsOnlyEmojis())
|
||||
Assert.assertTrue("😀".containsOnlyEmojis())
|
||||
Assert.assertTrue("🙂🙁".containsOnlyEmojis())
|
||||
Assert.assertTrue("👁❤️🍝".containsOnlyEmojis()) // 👁 is a pictographic
|
||||
Assert.assertTrue("👨👩👦1️⃣🚀👳🏾♂️🪩".containsOnlyEmojis())
|
||||
Assert.assertTrue("🌍🌎🌏".containsOnlyEmojis())
|
||||
assertTrue("👍".containsOnlyEmojisInternal())
|
||||
assertTrue("😀".containsOnlyEmojisInternal())
|
||||
assertTrue("🙂🙁".containsOnlyEmojisInternal())
|
||||
assertTrue("👁❤️🍝".containsOnlyEmojisInternal()) // 👁 is a pictographic
|
||||
assertTrue("👨👩👦1️⃣🚀👳🏾♂️🪩".containsOnlyEmojisInternal())
|
||||
assertTrue("🌍🌎🌏".containsOnlyEmojisInternal())
|
||||
|
||||
// Awkward multi-codepoint graphemes
|
||||
Assert.assertTrue("🧑🧑🧒🧒".containsOnlyEmojis())
|
||||
Assert.assertTrue("🏴☠".containsOnlyEmojis())
|
||||
Assert.assertTrue("👩🏿🔧".containsOnlyEmojis())
|
||||
assertTrue("🧑🧑🧒🧒".containsOnlyEmojisInternal())
|
||||
assertTrue("🏴☠".containsOnlyEmojisInternal())
|
||||
assertTrue("👩🏿🔧".containsOnlyEmojisInternal())
|
||||
|
||||
Assert.assertFalse("".containsOnlyEmojis())
|
||||
Assert.assertFalse(" ".containsOnlyEmojis())
|
||||
Assert.assertFalse("🙂 🙁".containsOnlyEmojis())
|
||||
Assert.assertFalse(" 🙂 🙁 ".containsOnlyEmojis())
|
||||
Assert.assertFalse("Hello".containsOnlyEmojis())
|
||||
Assert.assertFalse("Hello 👋".containsOnlyEmojis())
|
||||
Assert.assertFalse("".containsOnlyEmojisInternal())
|
||||
Assert.assertFalse(" ".containsOnlyEmojisInternal())
|
||||
Assert.assertFalse("🙂 🙁".containsOnlyEmojisInternal())
|
||||
Assert.assertFalse(" 🙂 🙁 ".containsOnlyEmojisInternal())
|
||||
Assert.assertFalse("Hello".containsOnlyEmojisInternal())
|
||||
Assert.assertFalse("Hello 👋".containsOnlyEmojisInternal())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import com.bumble.appyx.core.plugin.plugins
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.appconfig.SecureBackupConfig
|
||||
import io.element.android.appconfig.LearnMoreConfig
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
@@ -59,7 +59,7 @@ class SecureBackupRootNode @AssistedInject constructor(
|
||||
}
|
||||
|
||||
private fun onLearnMoreClick(uriHandler: UriHandler) {
|
||||
uriHandler.openUri(SecureBackupConfig.LEARN_MORE_URL)
|
||||
uriHandler.openUri(LearnMoreConfig.SECURE_BACKUP_URL)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -13,6 +13,7 @@ import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.browser.customtabs.CustomTabsSession
|
||||
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
|
||||
|
||||
/**
|
||||
* Open url in custom tab or, if not available, in the default browser.
|
||||
@@ -53,6 +54,6 @@ fun Activity.openUrlInChromeCustomTab(
|
||||
}
|
||||
.launchUrl(this, Uri.parse(url))
|
||||
} catch (activityNotFoundException: ActivityNotFoundException) {
|
||||
// TODO context.toast(R.string.error_no_external_application_found)
|
||||
openUrlInExternalApp(url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
TimelineReadReceipt(16.dp),
|
||||
|
||||
ComposerAlert(32.dp),
|
||||
|
||||
ReadReceiptList(32.dp),
|
||||
|
||||
MessageActionSender(32.dp),
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
package io.element.android.libraries.matrix.api.encryption
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@@ -58,6 +59,11 @@ interface EncryptionService {
|
||||
* Starts the identity reset process. This will return a handle that can be used to reset the identity.
|
||||
*/
|
||||
suspend fun startIdentityReset(): Result<IdentityResetHandle?>
|
||||
|
||||
/**
|
||||
* Remember this identity, ensuring it does not result in a pin violation.
|
||||
*/
|
||||
suspend fun pinUserIdentity(userId: UserId): Result<Unit>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.UniqueId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||
@@ -52,6 +53,7 @@ interface MatrixRoom : Closeable {
|
||||
|
||||
val roomInfoFlow: Flow<MatrixRoomInfo>
|
||||
val roomTypingMembersFlow: Flow<List<UserId>>
|
||||
val identityStateChangesFlow: Flow<List<IdentityStateChange>>
|
||||
|
||||
/**
|
||||
* A one-to-one is a room with exactly 2 members.
|
||||
|
||||
@@ -11,6 +11,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.extensions.flatMap
|
||||
import io.element.android.libraries.core.extensions.mapFailure
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.BackupState
|
||||
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
||||
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
|
||||
@@ -202,4 +203,9 @@ internal class RustEncryptionService(
|
||||
RustIdentityResetHandleFactory.create(sessionId, handle)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun pinUserIdentity(userId: UserId): Result<Unit> = runCatching {
|
||||
val userIdentity = service.getUserIdentity(userId.value) ?: error("User identity not found")
|
||||
userIdentity.pin()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.UniqueId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||
@@ -43,6 +44,7 @@ import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
||||
import io.element.android.libraries.matrix.impl.mapper.map
|
||||
import io.element.android.libraries.matrix.impl.room.draft.into
|
||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
|
||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
|
||||
@@ -69,6 +71,7 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
|
||||
import org.matrix.rustcomponents.sdk.RoomInfo
|
||||
import org.matrix.rustcomponents.sdk.RoomInfoListener
|
||||
import org.matrix.rustcomponents.sdk.RoomListItem
|
||||
@@ -82,6 +85,7 @@ import timber.log.Timber
|
||||
import uniffi.matrix_sdk.RoomPowerLevelChanges
|
||||
import java.io.File
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import org.matrix.rustcomponents.sdk.IdentityStatusChange as RustIdentityStateChange
|
||||
import org.matrix.rustcomponents.sdk.Room as InnerRoom
|
||||
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
||||
|
||||
@@ -130,6 +134,23 @@ class RustMatrixRoom(
|
||||
})
|
||||
}
|
||||
|
||||
override val identityStateChangesFlow: Flow<List<IdentityStateChange>> = mxCallbackFlow {
|
||||
val initial = emptyList<IdentityStateChange>()
|
||||
channel.trySend(initial)
|
||||
innerRoom.subscribeToIdentityStatusChanges(object : IdentityStatusChangeListener {
|
||||
override fun call(identityStatusChange: List<RustIdentityStateChange>) {
|
||||
channel.trySend(
|
||||
identityStatusChange.map {
|
||||
IdentityStateChange(
|
||||
userId = UserId(it.userId),
|
||||
identityState = it.changedTo.map(),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Create a dispatcher for all room methods...
|
||||
private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
package io.element.android.libraries.matrix.test.encryption
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.BackupState
|
||||
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
||||
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
|
||||
@@ -21,6 +22,7 @@ import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
class FakeEncryptionService(
|
||||
var startIdentityResetLambda: () -> Result<IdentityResetHandle?> = { lambdaError() },
|
||||
private val pinUserIdentityResult: (UserId) -> Result<Unit> = { lambdaError() },
|
||||
) : EncryptionService {
|
||||
private var disableRecoveryFailure: Exception? = null
|
||||
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
|
||||
@@ -117,6 +119,10 @@ class FakeEncryptionService(
|
||||
return startIdentityResetLambda()
|
||||
}
|
||||
|
||||
override suspend fun pinUserIdentity(userId: UserId): Result<Unit> {
|
||||
return pinUserIdentityResult(userId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FAKE_RECOVERY_KEY = "fake"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||
@@ -137,7 +138,7 @@ class FakeMatrixRoom(
|
||||
private val subscribeToSyncLambda: () -> Unit = { lambdaError() },
|
||||
private val ignoreDeviceTrustAndResendResult: (Map<UserId, List<DeviceId>>, TransactionId) -> Result<Unit> = { _, _ -> lambdaError() },
|
||||
private val withdrawVerificationAndResendResult: (List<UserId>, TransactionId) -> Result<Unit> = { _, _ -> lambdaError() },
|
||||
) : MatrixRoom {
|
||||
) : MatrixRoom {
|
||||
private val _roomInfoFlow: MutableSharedFlow<MatrixRoomInfo> = MutableSharedFlow(replay = 1)
|
||||
override val roomInfoFlow: Flow<MatrixRoomInfo> = _roomInfoFlow
|
||||
|
||||
@@ -152,6 +153,13 @@ class FakeMatrixRoom(
|
||||
_roomTypingMembersFlow.tryEmit(typingMembers)
|
||||
}
|
||||
|
||||
private val _identityStateChangesFlow: MutableSharedFlow<List<IdentityStateChange>> = MutableSharedFlow(replay = 1)
|
||||
override val identityStateChangesFlow: Flow<List<IdentityStateChange>> = _identityStateChangesFlow
|
||||
|
||||
fun emitIdentityStateChanges(identityStateChanges: List<IdentityStateChange>) {
|
||||
_identityStateChangesFlow.tryEmit(identityStateChanges)
|
||||
}
|
||||
|
||||
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
|
||||
|
||||
override val roomNotificationSettingsStateFlow: MutableStateFlow<MatrixRoomNotificationSettingsState> =
|
||||
|
||||
@@ -75,6 +75,7 @@ class KonsistPreviewTest {
|
||||
"MessageComposerViewVoicePreview",
|
||||
"MessagesReactionButtonAddPreview",
|
||||
"MessagesReactionButtonExtraPreview",
|
||||
"MessagesViewWithIdentityChangePreview",
|
||||
"MessagesViewWithTypingPreview",
|
||||
"PageTitleWithIconFullPreview",
|
||||
"PageTitleWithIconMinimalPreview",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user