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

Warn the user when unverified user has changed their identity
This commit is contained in:
Benoit Marty
2024-10-08 16:33:14 +02:00
committed by GitHub
88 changed files with 967 additions and 210 deletions

View File

@@ -7,6 +7,7 @@
package io.element.android.appconfig
object SecureBackupConfig {
const val LEARN_MORE_URL: String = "https://element.io/help#encryption5"
object LearnMoreConfig {
const val SECURE_BACKUP_URL: String = "https://element.io/help#encryption5"
const val IDENTITY_CHANGE_URL: String = "https://element.io/help#encryption18"
}

View File

@@ -7,6 +7,7 @@
package io.element.android.features.messages.impl
import android.app.Activity
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -26,19 +27,19 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.androidutils.system.toast
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
import io.element.android.libraries.matrix.api.core.EventId
@@ -63,8 +64,6 @@ class MessagesNode @AssistedInject constructor(
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser,
@ApplicationContext
private val context: Context,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(this)
private val callbacks = plugins<Callback>()
@@ -124,7 +123,8 @@ class MessagesNode @AssistedInject constructor(
}
private fun onLinkClick(
context: Context,
activity: Activity,
darkTheme: Boolean,
url: String,
eventSink: (TimelineEvents) -> Unit,
) {
@@ -135,16 +135,20 @@ class MessagesNode @AssistedInject constructor(
callbacks.forEach { it.onUserDataClick(permalink.userId) }
}
is PermalinkData.RoomLink -> {
handleRoomLinkClick(permalink, eventSink)
handleRoomLinkClick(activity, permalink, eventSink)
}
is PermalinkData.FallbackLink,
is PermalinkData.RoomEmailInviteLink -> {
context.openUrlInExternalApp(url)
activity.openUrlInChromeCustomTab(null, darkTheme, url)
}
}
}
private fun handleRoomLinkClick(roomLink: PermalinkData.RoomLink, eventSink: (TimelineEvents) -> Unit) {
private fun handleRoomLinkClick(
context: Context,
roomLink: PermalinkData.RoomLink,
eventSink: (TimelineEvents) -> Unit,
) {
if (room.matches(roomLink.roomIdOrAlias)) {
val eventId = roomLink.eventId
if (eventId != null) {
@@ -192,7 +196,8 @@ class MessagesNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val context = LocalContext.current
val activity = LocalContext.current as Activity
val isDark = ElementTheme.isLightTheme.not()
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
@@ -210,7 +215,7 @@ class MessagesNode @AssistedInject constructor(
onEventClick = this::onEventClick,
onPreviewAttachments = this::onPreviewAttachments,
onUserDataClick = this::onUserDataClick,
onLinkClick = { onLinkClick(context, it, state.timelineState.eventSink) },
onLinkClick = { url -> onLinkClick(activity, isDark, url, state.timelineState.eventSink) },
onSendLocationClick = this::onSendLocationClick,
onCreatePollClick = this::onCreatePollClick,
onJoinCallClick = this::onJoinCallClick,

View File

@@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
@@ -91,6 +92,7 @@ class MessagesPresenter @AssistedInject constructor(
private val voiceMessageComposerPresenter: Presenter<VoiceMessageComposerState>,
timelinePresenterFactory: TimelinePresenter.Factory,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
private val actionListPresenterFactory: ActionListPresenter.Factory,
private val customReactionPresenter: Presenter<CustomReactionState>,
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
@@ -125,6 +127,7 @@ class MessagesPresenter @AssistedInject constructor(
val voiceMessageComposerState = voiceMessageComposerPresenter.present()
val timelineState = timelinePresenter.present()
val timelineProtectionState = timelineProtectionPresenter.present()
val identityChangeState = identityChangeStatePresenter.present()
val actionListState = actionListPresenter.present()
val customReactionState = customReactionPresenter.present()
val reactionSummaryState = reactionSummaryPresenter.present()
@@ -217,6 +220,7 @@ class MessagesPresenter @AssistedInject constructor(
voiceMessageComposerState = voiceMessageComposerState,
timelineState = timelineState,
timelineProtectionState = timelineProtectionState,
identityChangeState = identityChangeState,
actionListState = actionListState,
customReactionState = customReactionState,
reactionSummaryState = reactionSummaryState,

View File

@@ -9,6 +9,7 @@ package io.element.android.features.messages.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineState
@@ -34,6 +35,7 @@ data class MessagesState(
val voiceMessageComposerState: VoiceMessageComposerState,
val timelineState: TimelineState,
val timelineProtectionState: TimelineProtectionState,
val identityChangeState: IdentityChangeState,
val actionListState: ActionListState,
val customReactionState: CustomReactionState,
val reactionSummaryState: ReactionSummaryState,

View File

@@ -10,6 +10,8 @@ package io.element.android.features.messages.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
@@ -106,6 +108,7 @@ fun aMessagesState(
focusedEventIndex = 2,
),
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
identityChangeState: IdentityChangeState = anIdentityChangeState(),
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
actionListState: ActionListState = anActionListState(),
customReactionState: CustomReactionState = aCustomReactionState(),
@@ -125,6 +128,7 @@ fun aMessagesState(
composerState = composerState,
voiceMessageComposerState = voiceMessageComposerState,
timelineProtectionState = timelineProtectionState,
identityChangeState = identityChangeState,
timelineState = timelineState,
readReceiptBottomSheetState = readReceiptBottomSheetState,
actionListState = actionListState,

View File

@@ -57,6 +57,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
@@ -103,6 +104,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import timber.log.Timber
@@ -415,6 +417,7 @@ private fun MessagesViewContent(
MessagesViewComposerBottomSheetContents(
subcomposing = subcomposing,
state = state,
onLinkClick = onLinkClick,
)
},
sheetContentKey = sheetResizeContentKey.intValue,
@@ -428,6 +431,7 @@ private fun MessagesViewContent(
private fun MessagesViewComposerBottomSheetContents(
subcomposing: Boolean,
state: MessagesState,
onLinkClick: (String) -> Unit,
) {
if (state.userEventPermissions.canSendMessage) {
Column(modifier = Modifier.fillMaxWidth()) {
@@ -448,6 +452,14 @@ private fun MessagesViewComposerBottomSheetContents(
state.composerState.eventSink(MessageComposerEvents.InsertSuggestion(it))
}
)
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
if (state.composerState.suggestions.isEmpty() &&
state.composerState.textEditorState is TextEditorState.Markdown) {
IdentityChangeStateView(
state = state.identityChangeState,
onLinkClick = onLinkClick,
)
}
MessageComposerView(
state = state.composerState,
voiceMessageState = state.voiceMessageComposerState,

View File

@@ -0,0 +1,14 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.identity
import io.element.android.libraries.matrix.api.core.UserId
sealed interface IdentityChangeEvent {
data class Submit(val userId: UserId) : IdentityChangeEvent
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.identity
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import kotlinx.collections.immutable.ImmutableList
data class IdentityChangeState(
val roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange>,
val eventSink: (IdentityChangeEvent) -> Unit,
)
data class RoomMemberIdentityStateChange(
val identityRoomMember: IdentityRoomMember,
val identityState: IdentityState,
)

View File

@@ -0,0 +1,102 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.identity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProduceStateScope
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.ui.model.getAvatarData
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
class IdentityChangeStatePresenter @Inject constructor(
private val room: MatrixRoom,
private val encryptionService: EncryptionService,
) : Presenter<IdentityChangeState> {
@Composable
override fun present(): IdentityChangeState {
val coroutineScope = rememberCoroutineScope()
val roomMemberIdentityStateChange by produceState(persistentListOf()) {
observeRoomMemberIdentityStateChange()
}
fun handleEvent(event: IdentityChangeEvent) {
when (event) {
is IdentityChangeEvent.Submit -> coroutineScope.pinUserIdentity(event.userId)
}
}
return IdentityChangeState(
roomMemberIdentityStateChanges = roomMemberIdentityStateChange,
eventSink = ::handleEvent,
)
}
private fun ProduceStateScope<PersistentList<RoomMemberIdentityStateChange>>.observeRoomMemberIdentityStateChange() {
combine(room.identityStateChangesFlow, room.membersStateFlow) { identityStateChanges, membersState ->
identityStateChanges.map { identityStateChange ->
val member = membersState.roomMembers()
?.firstOrNull { roomMember -> roomMember.userId == identityStateChange.userId }
?.toIdentityRoomMember()
?: createDefaultRoomMemberForIdentityChange(identityStateChange.userId)
RoomMemberIdentityStateChange(
identityRoomMember = member,
identityState = identityStateChange.identityState,
)
}
}
.distinctUntilChanged()
.onEach { roomMemberIdentityStateChanges ->
value = roomMemberIdentityStateChanges.toPersistentList()
}
.launchIn(this)
}
private fun CoroutineScope.pinUserIdentity(userId: UserId) = launch {
encryptionService.pinUserIdentity(userId)
.onFailure {
Timber.e(it, "Failed to pin identity for user $userId")
}
}
}
private fun RoomMember.toIdentityRoomMember() = IdentityRoomMember(
userId = userId,
disambiguatedDisplayName = disambiguatedDisplayName,
avatarData = getAvatarData(AvatarSize.ComposerAlert),
)
private fun createDefaultRoomMemberForIdentityChange(userId: UserId) = IdentityRoomMember(
userId = userId,
disambiguatedDisplayName = userId.value,
avatarData = AvatarData(
id = userId.value,
name = null,
url = null,
size = AvatarSize.ComposerAlert,
),
)

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.identity
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import kotlinx.collections.immutable.toImmutableList
class IdentityChangeStateProvider : PreviewParameterProvider<IdentityChangeState> {
override val values: Sequence<IdentityChangeState>
get() = sequenceOf(
anIdentityChangeState(),
anIdentityChangeState(
roomMemberIdentityStateChanges = listOf(
RoomMemberIdentityStateChange(
identityRoomMember = anIdentityRoomMember(disambiguatedDisplayName = "Alice"),
identityState = IdentityState.PinViolation,
),
),
),
)
}
internal fun anIdentityChangeState(
roomMemberIdentityStateChanges: List<RoomMemberIdentityStateChange> = emptyList(),
) = IdentityChangeState(
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges.toImmutableList(),
eventSink = {},
)
internal fun anIdentityRoomMember(
userId: UserId = UserId("@alice:example.com"),
disambiguatedDisplayName: String = userId.value,
avatarData: AvatarData = AvatarData(
id = userId.value,
name = null,
url = null,
size = AvatarSize.ComposerAlert,
),
) = IdentityRoomMember(
userId = userId,
disambiguatedDisplayName = disambiguatedDisplayName,
avatarData = avatarData,
)

View File

@@ -0,0 +1,84 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.identity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.appconfig.LearnMoreConfig
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun IdentityChangeStateView(
state: IdentityChangeState,
onLinkClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
// Pick the first identity change to PinViolation
val pinViolationIdentityChange = state.roomMemberIdentityStateChanges.firstOrNull {
// For now only render PinViolation
it.identityState == IdentityState.PinViolation
}
if (pinViolationIdentityChange != null) {
ComposerAlertMolecule(
modifier = modifier,
avatar = pinViolationIdentityChange.identityRoomMember.avatarData,
content = buildAnnotatedString {
val learnMoreStr = stringResource(CommonStrings.action_learn_more)
val fullText = stringResource(
id = CommonStrings.crypto_identity_change_pin_violation,
pinViolationIdentityChange.identityRoomMember.disambiguatedDisplayName,
learnMoreStr,
)
val learnMoreStartIndex = fullText.indexOf(learnMoreStr)
append(fullText)
addStyle(
style = SpanStyle(
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Bold,
),
start = learnMoreStartIndex,
end = learnMoreStartIndex + learnMoreStr.length,
)
addLink(
url = LinkAnnotation.Url(
url = LearnMoreConfig.IDENTITY_CHANGE_URL,
linkInteractionListener = {
onLinkClick(LearnMoreConfig.IDENTITY_CHANGE_URL)
}
),
start = learnMoreStartIndex,
end = learnMoreStartIndex + learnMoreStr.length,
)
},
onSubmitClick = { state.eventSink(IdentityChangeEvent.Submit(pinViolationIdentityChange.identityRoomMember.userId)) },
isCritical = pinViolationIdentityChange.identityState == IdentityState.VerificationViolation,
)
}
}
@PreviewsDayNight
@Composable
internal fun IdentityChangeStateViewPreview(
@PreviewParameter(IdentityChangeStateProvider::class) state: IdentityChangeState,
) = ElementPreview {
IdentityChangeStateView(
state = state,
onLinkClick = {},
)
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.identity
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.UserId
data class IdentityRoomMember(
val userId: UserId,
val disambiguatedDisplayName: String,
val avatarData: AvatarData,
)

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.identity
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.MessagesView
import io.element.android.features.messages.impl.aMessagesState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.TextEditorState
@PreviewsDayNight
@Composable
internal fun MessagesViewWithIdentityChangePreview(
@PreviewParameter(IdentityChangeStateProvider::class) identityChangeState: IdentityChangeState
) = ElementPreview {
MessagesView(
state = aMessagesState(
composerState = aMessageComposerState(
textEditorState = TextEditorState.Markdown(
state = MarkdownTextEditorState(
initialText = "",
initialFocus = false,
)
)
),
identityChangeState = identityChangeState,
),
onBackClick = {},
onRoomDetailsClick = {},
onEventClick = { false },
onUserDataClick = {},
onLinkClick = {},
onPreviewAttachments = {},
onSendLocationClick = {},
onCreatePollClick = {},
onJoinCallClick = {},
onViewAllPinnedMessagesClick = {},
)
}

View File

@@ -10,6 +10,8 @@ package io.element.android.features.messages.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
@@ -60,4 +62,7 @@ interface MessagesModule {
@Binds
fun bindReadReceiptBottomSheetPresenter(presenter: ReadReceiptBottomSheetPresenter): Presenter<ReadReceiptBottomSheetState>
@Binds
fun bindIdentityChangeStatePresenter(presenter: IdentityChangeStatePresenter): Presenter<IdentityChangeState>
}

View File

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

View File

@@ -9,21 +9,22 @@ package io.element.android.features.messages.impl.typing
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.ProduceStateScope
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
@@ -36,64 +37,56 @@ class TypingNotificationPresenter @Inject constructor(
) : Presenter<TypingNotificationState> {
@Composable
override fun present(): TypingNotificationState {
val typingMembersState = remember { mutableStateOf(emptyList<RoomMember>()) }
val renderTypingNotifications by sessionPreferencesStore.isRenderTypingNotificationsEnabled().collectAsState(initial = true)
LaunchedEffect(renderTypingNotifications) {
val typingMembersState by produceState(initialValue = persistentListOf(), key1 = renderTypingNotifications) {
if (renderTypingNotifications) {
observeRoomTypingMembers(typingMembersState)
observeRoomTypingMembers()
} else {
typingMembersState.value = emptyList()
value = persistentListOf<TypingRoomMember>()
}
}
// This will keep the space reserved for the typing notifications after the first one is displayed
var reserveSpace by remember { mutableStateOf(false) }
LaunchedEffect(renderTypingNotifications, typingMembersState.value) {
if (renderTypingNotifications && typingMembersState.value.isNotEmpty()) {
LaunchedEffect(renderTypingNotifications, typingMembersState) {
if (renderTypingNotifications && typingMembersState.isNotEmpty()) {
reserveSpace = true
}
}
return TypingNotificationState(
renderTypingNotifications = renderTypingNotifications,
typingMembers = typingMembersState.value.toImmutableList(),
typingMembers = typingMembersState,
reserveSpace = reserveSpace,
)
}
private fun CoroutineScope.observeRoomTypingMembers(typingMembersState: MutableState<List<RoomMember>>) {
private fun ProduceStateScope<ImmutableList<TypingRoomMember>>.observeRoomTypingMembers() {
combine(room.roomTypingMembersFlow, room.membersStateFlow) { typingMembers, membersState ->
typingMembers
.map { userId ->
membersState.roomMembers()
?.firstOrNull { roomMember -> roomMember.userId == userId }
?.toTypingRoomMember()
?: createDefaultRoomMemberForTyping(userId)
}
}
.distinctUntilChanged()
.onEach { members ->
typingMembersState.value = members
value = members.toImmutableList()
}
.launchIn(this)
}
}
/**
* Create a default [RoomMember] for typing events.
* In this case, only the userId will be used for rendering, other fields are not used, but keep them
* as close as possible to the actual data.
*/
private fun createDefaultRoomMemberForTyping(userId: UserId): RoomMember {
return RoomMember(
userId = userId,
displayName = null,
avatarUrl = null,
membership = RoomMembershipState.JOIN,
isNameAmbiguous = false,
powerLevel = 0,
normalizedPowerLevel = 0,
isIgnored = false,
role = RoomMember.Role.USER,
private fun RoomMember.toTypingRoomMember(): TypingRoomMember {
return TypingRoomMember(
disambiguatedDisplayName = disambiguatedDisplayName,
)
}
private fun createDefaultRoomMemberForTyping(userId: UserId): TypingRoomMember {
return TypingRoomMember(
disambiguatedDisplayName = userId.value,
)
}

View File

@@ -7,7 +7,6 @@
package io.element.android.features.messages.impl.typing
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.ImmutableList
/**
@@ -17,7 +16,7 @@ data class TypingNotificationState(
/** Whether to render the typing notifications based on the user's preferences. */
val renderTypingNotifications: Boolean,
/** The room members currently typing. */
val typingMembers: ImmutableList<RoomMember>,
val typingMembers: ImmutableList<TypingRoomMember>,
/** Whether to reserve space for the typing notifications at the bottom of the timeline. */
val reserveSpace: Boolean,
)

View File

@@ -1,27 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.typing
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
class TypingNotificationStateForMessagesProvider : PreviewParameterProvider<TypingNotificationState> {
override val values: Sequence<TypingNotificationState>
get() = sequenceOf(
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
aTypingRoomMember(displayName = "Bob"),
),
),
aTypingNotificationState(
typingMembers = listOf(aTypingRoomMember()),
reserveSpace = true
),
aTypingNotificationState(reserveSpace = true),
)
}

View File

@@ -8,9 +8,6 @@
package io.element.android.features.messages.impl.typing
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import kotlinx.collections.immutable.toImmutableList
class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificationState> {
@@ -24,39 +21,39 @@ class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificat
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice", isNameAmbiguous = true),
aTypingRoomMember(disambiguatedDisplayName = "Alice (@alice:example.com)"),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
aTypingRoomMember(displayName = "Bob"),
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
aTypingRoomMember(disambiguatedDisplayName = "Bob"),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
aTypingRoomMember(displayName = "Bob"),
aTypingRoomMember(displayName = "Charlie"),
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
aTypingRoomMember(disambiguatedDisplayName = "Bob"),
aTypingRoomMember(disambiguatedDisplayName = "Charlie"),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
aTypingRoomMember(displayName = "Bob"),
aTypingRoomMember(displayName = "Charlie"),
aTypingRoomMember(displayName = "Dan"),
aTypingRoomMember(displayName = "Eve"),
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
aTypingRoomMember(disambiguatedDisplayName = "Bob"),
aTypingRoomMember(disambiguatedDisplayName = "Charlie"),
aTypingRoomMember(disambiguatedDisplayName = "Dan"),
aTypingRoomMember(disambiguatedDisplayName = "Eve"),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice with a very long display name which means that it will be truncated"),
aTypingRoomMember(disambiguatedDisplayName = "Alice with a very long display name which means that it will be truncated"),
),
),
aTypingNotificationState(
@@ -67,7 +64,7 @@ class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificat
}
internal fun aTypingNotificationState(
typingMembers: List<RoomMember> = emptyList(),
typingMembers: List<TypingRoomMember> = emptyList(),
reserveSpace: Boolean = false,
) = TypingNotificationState(
renderTypingNotifications = true,
@@ -76,19 +73,7 @@ internal fun aTypingNotificationState(
)
internal fun aTypingRoomMember(
userId: UserId = UserId("@alice:example.com"),
displayName: String? = null,
isNameAmbiguous: Boolean = false,
): RoomMember {
return RoomMember(
userId = userId,
displayName = displayName,
avatarUrl = null,
membership = RoomMembershipState.JOIN,
isNameAmbiguous = isNameAmbiguous,
powerLevel = 0,
normalizedPowerLevel = 0,
isIgnored = false,
role = RoomMember.Role.USER,
)
}
disambiguatedDisplayName: String = "@alice:example.com",
) = TypingRoomMember(
disambiguatedDisplayName = disambiguatedDisplayName,
)

View File

@@ -41,7 +41,6 @@ import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.ImmutableList
@Suppress("MultipleEmitters") // False positive
@@ -53,7 +52,8 @@ fun TypingNotificationView(
val displayNotifications = state.typingMembers.isNotEmpty() && state.renderTypingNotifications
@Suppress("ModifierNaming")
@Composable fun TypingText(text: AnnotatedString, textModifier: Modifier = Modifier) {
@Composable
fun TypingText(text: AnnotatedString, textModifier: Modifier = Modifier) {
Text(
modifier = textModifier,
text = text,
@@ -66,7 +66,9 @@ fun TypingNotificationView(
// Display the typing notification space when either a typing notification needs to be displayed or a previous one already was
AnimatedVisibility(
modifier = modifier.fillMaxWidth().padding(vertical = 2.dp),
modifier = modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
visible = displayNotifications || state.reserveSpace,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
@@ -95,7 +97,7 @@ fun TypingNotificationView(
}
@Composable
private fun computeTypingNotificationText(typingMembers: ImmutableList<RoomMember>): AnnotatedString {
private fun computeTypingNotificationText(typingMembers: ImmutableList<TypingRoomMember>): AnnotatedString {
// Remember the last value to avoid empty typing messages while animating
var result by remember { mutableStateOf(AnnotatedString("")) }
if (typingMembers.isNotEmpty()) {

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.typing
data class TypingRoomMember(
val disambiguatedDisplayName: String,
)

View File

@@ -7,17 +7,25 @@
package io.element.android.features.messages.impl.utils
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalInspectionMode
import com.sigpwned.emoji4j.core.Grapheme.Type.EMOJI
import com.sigpwned.emoji4j.core.Grapheme.Type.PICTOGRAPHIC
import com.sigpwned.emoji4j.core.GraphemeMatchResult
import com.sigpwned.emoji4j.core.GraphemeMatcher
import io.element.android.features.messages.impl.timeline.model.event.AN_EMOJI_ONLY_TEXT
/**
* Returns true if the string consists exclusively of "emoji or pictographic graphemes".
*/
@Composable
fun String.containsOnlyEmojis(): Boolean {
if (LocalInspectionMode.current) return this == AN_EMOJI_ONLY_TEXT
if (isEmpty()) return false
return containsOnlyEmojisInternal()
}
internal fun String.containsOnlyEmojisInternal(): Boolean {
val matcher = GraphemeMatcher(this)
var m: GraphemeMatchResult? = null
var contiguous = true

View File

@@ -16,6 +16,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
@@ -1026,6 +1027,7 @@ class MessagesPresenterTest {
customReactionPresenter = { aCustomReactionState() },
reactionSummaryPresenter = { aReactionSummaryState() },
readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() },
identityChangeStatePresenter = { anIdentityChangeState() },
pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(),

View File

@@ -0,0 +1,129 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.identity
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class IdentityChangeStatePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createIdentityChangeStatePresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.roomMemberIdentityStateChanges).isEmpty()
}
}
@Test
fun `present - when the room emits identity change, the presenter emits new state`() = runTest {
val room = FakeMatrixRoom()
val presenter = createIdentityChangeStatePresenter(room)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.roomMemberIdentityStateChanges).isEmpty()
room.emitIdentityStateChanges(
listOf(
IdentityStateChange(
userId = A_USER_ID_2,
identityState = IdentityState.PinViolation,
),
)
)
val finalItem = awaitItem()
assertThat(finalItem.roomMemberIdentityStateChanges).hasSize(1)
val value = finalItem.roomMemberIdentityStateChanges.first()
assertThat(value.identityRoomMember.userId).isEqualTo(A_USER_ID_2)
assertThat(value.identityState).isEqualTo(IdentityState.PinViolation)
}
}
@Test
fun `present - when the room emits identity change, the presenter emits new state with member details`() =
runTest {
val room = FakeMatrixRoom().apply {
givenRoomMembersState(
MatrixRoomMembersState.Ready(
listOf(
aRoomMember(
A_USER_ID_2,
displayName = "Alice",
),
).toImmutableList()
)
)
}
val presenter = createIdentityChangeStatePresenter(room)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.roomMemberIdentityStateChanges).isEmpty()
room.emitIdentityStateChanges(
listOf(
IdentityStateChange(
userId = A_USER_ID_2,
identityState = IdentityState.PinViolation,
),
)
)
val finalItem = awaitItem()
assertThat(finalItem.roomMemberIdentityStateChanges).hasSize(1)
val value = finalItem.roomMemberIdentityStateChanges.first()
assertThat(value.identityRoomMember.userId).isEqualTo(A_USER_ID_2)
assertThat(value.identityRoomMember.disambiguatedDisplayName).isEqualTo("Alice")
assertThat(value.identityRoomMember.avatarData.size).isEqualTo(AvatarSize.ComposerAlert)
assertThat(value.identityState).isEqualTo(IdentityState.PinViolation)
}
}
@Test
fun `present - when the user pin the identity, the presenter invokes the encryption service api`() =
runTest {
val lambda = lambdaRecorder<UserId, Result<Unit>> { Result.success(Unit) }
val encryptionService = FakeEncryptionService(
pinUserIdentityResult = lambda,
)
val presenter = createIdentityChangeStatePresenter(encryptionService = encryptionService)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(IdentityChangeEvent.Submit(A_USER_ID))
lambda.assertions().isCalledOnce().with(value(A_USER_ID))
}
}
private fun createIdentityChangeStatePresenter(
room: MatrixRoom = FakeMatrixRoom(),
encryptionService: EncryptionService = FakeEncryptionService(),
): IdentityChangeStatePresenter {
return IdentityChangeStatePresenter(
room = room,
encryptionService = encryptionService,
)
}
}

View File

@@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
@@ -49,7 +50,6 @@ class TypingNotificationPresenterTest {
@Test
fun `present - typing notification disabled`() = runTest {
val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2)
val room = FakeMatrixRoom()
val sessionPreferencesStore = InMemorySessionPreferencesStore(
isRenderTypingNotificationsEnabled = false
@@ -73,7 +73,11 @@ class TypingNotificationPresenterTest {
val oneMemberTypingState = awaitItem()
assertThat(oneMemberTypingState.renderTypingNotifications).isTrue()
assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1)
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember)
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(
TypingRoomMember(
disambiguatedDisplayName = A_USER_ID_2.value,
)
)
// Preferences changes again
sessionPreferencesStore.setRenderTypingNotifications(false)
skipItems(2)
@@ -85,7 +89,6 @@ class TypingNotificationPresenterTest {
@Test
fun `present - state is updated when a member is typing, member is not known`() = runTest {
val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2)
val room = FakeMatrixRoom()
val presenter = createPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -96,7 +99,11 @@ class TypingNotificationPresenterTest {
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
val oneMemberTypingState = awaitItem()
assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1)
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember)
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(
TypingRoomMember(
disambiguatedDisplayName = A_USER_ID_2.value,
)
)
// User stops typing
room.givenRoomTypingMembers(emptyList())
skipItems(1)
@@ -129,7 +136,11 @@ class TypingNotificationPresenterTest {
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
val oneMemberTypingState = awaitItem()
assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1)
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aKnownRoomMember)
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(
TypingRoomMember(
disambiguatedDisplayName = "Alice Doe (@bob:server.org)",
)
)
// User stops typing
room.givenRoomTypingMembers(emptyList())
skipItems(1)
@@ -140,7 +151,6 @@ class TypingNotificationPresenterTest {
@Test
fun `present - state is updated when a member is typing, member is not known, then known`() = runTest {
val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2)
val aKnownRoomMember = createKnownRoomMember(A_USER_ID_2)
val room = FakeMatrixRoom()
val presenter = createPresenter(matrixRoom = room)
@@ -152,7 +162,11 @@ class TypingNotificationPresenterTest {
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
val oneMemberTypingState = awaitItem()
assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1)
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember)
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(
TypingRoomMember(
disambiguatedDisplayName = A_USER_ID_2.value,
)
)
// User is getting known
room.givenRoomMembersState(
MatrixRoomMembersState.Ready(
@@ -161,7 +175,11 @@ class TypingNotificationPresenterTest {
)
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.typingMembers.first()).isEqualTo(aKnownRoomMember)
assertThat(finalState.typingMembers.first()).isEqualTo(
TypingRoomMember(
disambiguatedDisplayName = "Alice Doe (@bob:server.org)",
)
)
}
}
@@ -202,17 +220,9 @@ class TypingNotificationPresenterTest {
sessionPreferencesStore = sessionPreferencesStore,
)
private fun createDefaultRoomMember(
userId: UserId,
) = aTypingRoomMember(
userId = userId,
displayName = null,
isNameAmbiguous = false,
)
private fun createKnownRoomMember(
userId: UserId,
) = aTypingRoomMember(
) = aRoomMember(
userId = userId,
displayName = "Alice Doe",
isNameAmbiguous = true,

View File

@@ -8,29 +8,30 @@
package io.element.android.features.messages.impl.utils
import org.junit.Assert
import org.junit.Assert.assertTrue
import org.junit.Test
class EmojiTest {
@Test
fun validEmojis() {
// Simple single/multiple single-codepoint emojis per string
Assert.assertTrue("👍".containsOnlyEmojis())
Assert.assertTrue("😀".containsOnlyEmojis())
Assert.assertTrue("🙂🙁".containsOnlyEmojis())
Assert.assertTrue("👁❤️🍝".containsOnlyEmojis()) // 👁 is a pictographic
Assert.assertTrue("👨👩👦1⃣🚀👳🏾🪩".containsOnlyEmojis())
Assert.assertTrue("🌍🌎🌏".containsOnlyEmojis())
assertTrue("👍".containsOnlyEmojisInternal())
assertTrue("😀".containsOnlyEmojisInternal())
assertTrue("🙂🙁".containsOnlyEmojisInternal())
assertTrue("👁❤️🍝".containsOnlyEmojisInternal()) // 👁 is a pictographic
assertTrue("👨👩👦1⃣🚀👳🏾🪩".containsOnlyEmojisInternal())
assertTrue("🌍🌎🌏".containsOnlyEmojisInternal())
// Awkward multi-codepoint graphemes
Assert.assertTrue("🧑‍🧑‍🧒‍🧒".containsOnlyEmojis())
Assert.assertTrue("🏴‍☠".containsOnlyEmojis())
Assert.assertTrue("👩🏿‍🔧".containsOnlyEmojis())
assertTrue("🧑‍🧑‍🧒‍🧒".containsOnlyEmojisInternal())
assertTrue("🏴‍☠".containsOnlyEmojisInternal())
assertTrue("👩🏿‍🔧".containsOnlyEmojisInternal())
Assert.assertFalse("".containsOnlyEmojis())
Assert.assertFalse(" ".containsOnlyEmojis())
Assert.assertFalse("🙂 🙁".containsOnlyEmojis())
Assert.assertFalse(" 🙂 🙁 ".containsOnlyEmojis())
Assert.assertFalse("Hello".containsOnlyEmojis())
Assert.assertFalse("Hello 👋".containsOnlyEmojis())
Assert.assertFalse("".containsOnlyEmojisInternal())
Assert.assertFalse(" ".containsOnlyEmojisInternal())
Assert.assertFalse("🙂 🙁".containsOnlyEmojisInternal())
Assert.assertFalse(" 🙂 🙁 ".containsOnlyEmojisInternal())
Assert.assertFalse("Hello".containsOnlyEmojisInternal())
Assert.assertFalse("Hello 👋".containsOnlyEmojisInternal())
}
}

View File

@@ -18,7 +18,7 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.appconfig.SecureBackupConfig
import io.element.android.appconfig.LearnMoreConfig
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@@ -59,7 +59,7 @@ class SecureBackupRootNode @AssistedInject constructor(
}
private fun onLearnMoreClick(uriHandler: UriHandler) {
uriHandler.openUri(SecureBackupConfig.LEARN_MORE_URL)
uriHandler.openUri(LearnMoreConfig.SECURE_BACKUP_URL)
}
@Composable

View File

@@ -13,6 +13,7 @@ import android.net.Uri
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.browser.customtabs.CustomTabsSession
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
/**
* Open url in custom tab or, if not available, in the default browser.
@@ -53,6 +54,6 @@ fun Activity.openUrlInChromeCustomTab(
}
.launchUrl(this, Uri.parse(url))
} catch (activityNotFoundException: ActivityNotFoundException) {
// TODO context.toast(R.string.error_no_external_application_found)
openUrlInExternalApp(url)
}
}

View File

@@ -0,0 +1,105 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.designsystem.atomic.molecules
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toAnnotatedString
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.BooleanProvider
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun ComposerAlertMolecule(
avatar: AvatarData,
content: AnnotatedString,
onSubmitClick: () -> Unit,
modifier: Modifier = Modifier,
isCritical: Boolean = false,
submitText: String = stringResource(CommonStrings.action_ok),
) {
Column(
modifier.fillMaxWidth()
) {
val lineColor = if (isCritical) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.borderInfoSubtle
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(lineColor)
)
val startColor = if (isCritical) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgInfoSubtle
val brush = Brush.verticalGradient(
listOf(startColor, ElementTheme.materialColors.background),
)
Box(
modifier = Modifier
.background(brush)
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Avatar(
avatarData = avatar,
)
Text(
text = content,
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Start,
)
}
Button(
text = submitText,
size = ButtonSize.Medium,
modifier = Modifier.fillMaxWidth(),
onClick = onSubmitClick,
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun ComposerAlertMoleculePreview(@PreviewParameter(BooleanProvider::class) isCritical: Boolean) = ElementPreview {
ComposerAlertMolecule(
avatar = anAvatarData(size = AvatarSize.ComposerAlert),
content = "Alices verified identity has changed. Learn more".toAnnotatedString(),
isCritical = isCritical,
onSubmitClick = {},
)
}

View File

@@ -33,6 +33,8 @@ enum class AvatarSize(val dp: Dp) {
TimelineSender(32.dp),
TimelineReadReceipt(16.dp),
ComposerAlert(32.dp),
ReadReceiptList(32.dp),
MessageActionSender(32.dp),

View File

@@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.api.encryption
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@@ -58,6 +59,11 @@ interface EncryptionService {
* Starts the identity reset process. This will return a handle that can be used to reset the identity.
*/
suspend fun startIdentityReset(): Result<IdentityResetHandle?>
/**
* Remember this identity, ensuring it does not result in a pin violation.
*/
suspend fun pinUserIdentity(userId: UserId): Result<Unit>
}
/**

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.encryption.identity
enum class IdentityState {
/** The user is verified with us. */
Verified,
/**
* Either this is the first identity we have seen for this user, or the
* user has acknowledged a change of identity explicitly e.g. by
* clicking OK on a notification.
*/
Pinned,
/**
* The user's identity has changed since it was pinned. The user should be
* notified about this and given the opportunity to acknowledge the
* change, which will make the new identity pinned.
*/
PinViolation,
/**
* The user's identity has changed, and before that it was verified. This
* is a serious problem. The user can either verify again to make this
* identity verified, or withdraw verification to make it pinned.
*/
VerificationViolation,
}

View File

@@ -0,0 +1,15 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.encryption.identity
import io.element.android.libraries.matrix.api.core.UserId
data class IdentityStateChange(
val userId: UserId,
val identityState: IdentityState,
)

View File

@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
@@ -52,6 +53,7 @@ interface MatrixRoom : Closeable {
val roomInfoFlow: Flow<MatrixRoomInfo>
val roomTypingMembersFlow: Flow<List<UserId>>
val identityStateChangesFlow: Flow<List<IdentityStateChange>>
/**
* A one-to-one is a room with exactly 2 members.

View File

@@ -11,6 +11,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
@@ -202,4 +203,9 @@ internal class RustEncryptionService(
RustIdentityResetHandleFactory.create(sessionId, handle)
}
}
override suspend fun pinUserIdentity(userId: UserId): Result<Unit> = runCatching {
val userIdentity = service.getUserIdentity(userId.value) ?: error("User identity not found")
userIdentity.pin()
}
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.mapper
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import uniffi.matrix_sdk_crypto.IdentityState as RustIdentityState
fun RustIdentityState.map(): IdentityState = when (this) {
RustIdentityState.VERIFIED -> IdentityState.Verified
RustIdentityState.PINNED -> IdentityState.Pinned
RustIdentityState.PIN_VIOLATION -> IdentityState.PinViolation
RustIdentityState.VERIFICATION_VIOLATION -> IdentityState.VerificationViolation
}

View File

@@ -19,6 +19,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
@@ -43,6 +44,7 @@ import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import io.element.android.libraries.matrix.impl.mapper.map
import io.element.android.libraries.matrix.impl.room.draft.into
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
@@ -69,6 +71,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
import org.matrix.rustcomponents.sdk.RoomInfo
import org.matrix.rustcomponents.sdk.RoomInfoListener
import org.matrix.rustcomponents.sdk.RoomListItem
@@ -82,6 +85,7 @@ import timber.log.Timber
import uniffi.matrix_sdk.RoomPowerLevelChanges
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
import org.matrix.rustcomponents.sdk.IdentityStatusChange as RustIdentityStateChange
import org.matrix.rustcomponents.sdk.Room as InnerRoom
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
@@ -130,6 +134,23 @@ class RustMatrixRoom(
})
}
override val identityStateChangesFlow: Flow<List<IdentityStateChange>> = mxCallbackFlow {
val initial = emptyList<IdentityStateChange>()
channel.trySend(initial)
innerRoom.subscribeToIdentityStatusChanges(object : IdentityStatusChangeListener {
override fun call(identityStatusChange: List<RustIdentityStateChange>) {
channel.trySend(
identityStatusChange.map {
IdentityStateChange(
userId = UserId(it.userId),
identityState = it.changedTo.map(),
)
}
)
}
})
}
// Create a dispatcher for all room methods...
private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32)

View File

@@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.test.encryption
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
@@ -21,6 +22,7 @@ import kotlinx.coroutines.flow.flowOf
class FakeEncryptionService(
var startIdentityResetLambda: () -> Result<IdentityResetHandle?> = { lambdaError() },
private val pinUserIdentityResult: (UserId) -> Result<Unit> = { lambdaError() },
) : EncryptionService {
private var disableRecoveryFailure: Exception? = null
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
@@ -117,6 +119,10 @@ class FakeEncryptionService(
return startIdentityResetLambda()
}
override suspend fun pinUserIdentity(userId: UserId): Result<Unit> {
return pinUserIdentityResult(userId)
}
companion object {
const val FAKE_RECOVERY_KEY = "fake"
}

View File

@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
@@ -137,7 +138,7 @@ class FakeMatrixRoom(
private val subscribeToSyncLambda: () -> Unit = { lambdaError() },
private val ignoreDeviceTrustAndResendResult: (Map<UserId, List<DeviceId>>, TransactionId) -> Result<Unit> = { _, _ -> lambdaError() },
private val withdrawVerificationAndResendResult: (List<UserId>, TransactionId) -> Result<Unit> = { _, _ -> lambdaError() },
) : MatrixRoom {
) : MatrixRoom {
private val _roomInfoFlow: MutableSharedFlow<MatrixRoomInfo> = MutableSharedFlow(replay = 1)
override val roomInfoFlow: Flow<MatrixRoomInfo> = _roomInfoFlow
@@ -152,6 +153,13 @@ class FakeMatrixRoom(
_roomTypingMembersFlow.tryEmit(typingMembers)
}
private val _identityStateChangesFlow: MutableSharedFlow<List<IdentityStateChange>> = MutableSharedFlow(replay = 1)
override val identityStateChangesFlow: Flow<List<IdentityStateChange>> = _identityStateChangesFlow
fun emitIdentityStateChanges(identityStateChanges: List<IdentityStateChange>) {
_identityStateChangesFlow.tryEmit(identityStateChanges)
}
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
override val roomNotificationSettingsStateFlow: MutableStateFlow<MatrixRoomNotificationSettingsState> =

View File

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