Merge pull request #3621 from element-hq/feature/bma/composerAlert
Warn the user when unverified user has changed their identity
This commit is contained in:
commit
0ffd787187
88 changed files with 967 additions and 210 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue