Skip to content

Commit 0ffd787

Browse files
authored
Merge pull request #3621 from element-hq/feature/bma/composerAlert
Warn the user when unverified user has changed their identity
2 parents 0cad8ff + 873d807 commit 0ffd787

File tree

88 files changed

+967
-210
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

88 files changed

+967
-210
lines changed

appconfig/src/main/kotlin/io/element/android/appconfig/SecureBackupConfig.kt renamed to appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
package io.element.android.appconfig
99

10-
object SecureBackupConfig {
11-
const val LEARN_MORE_URL: String = "https://element.io/help#encryption5"
10+
object LearnMoreConfig {
11+
const val SECURE_BACKUP_URL: String = "https://element.io/help#encryption5"
12+
const val IDENTITY_CHANGE_URL: String = "https://element.io/help#encryption18"
1213
}

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
package io.element.android.features.messages.impl
99

10+
import android.app.Activity
1011
import android.content.Context
1112
import androidx.compose.runtime.Composable
1213
import androidx.compose.runtime.CompositionLocalProvider
@@ -26,19 +27,19 @@ import com.bumble.appyx.core.plugin.plugins
2627
import dagger.assisted.Assisted
2728
import dagger.assisted.AssistedInject
2829
import io.element.android.anvilannotations.ContributesNode
30+
import io.element.android.compound.theme.ElementTheme
2931
import io.element.android.features.messages.impl.attachments.Attachment
3032
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
3133
import io.element.android.features.messages.impl.timeline.TimelineEvents
3234
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
3335
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
3436
import io.element.android.features.messages.impl.timeline.model.TimelineItem
35-
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
37+
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
3638
import io.element.android.libraries.androidutils.system.toast
3739
import io.element.android.libraries.architecture.NodeInputs
3840
import io.element.android.libraries.architecture.inputs
3941
import io.element.android.libraries.core.bool.orFalse
4042
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
41-
import io.element.android.libraries.di.ApplicationContext
4243
import io.element.android.libraries.di.RoomScope
4344
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
4445
import io.element.android.libraries.matrix.api.core.EventId
@@ -63,8 +64,6 @@ class MessagesNode @AssistedInject constructor(
6364
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
6465
private val mediaPlayer: MediaPlayer,
6566
private val permalinkParser: PermalinkParser,
66-
@ApplicationContext
67-
private val context: Context,
6867
) : Node(buildContext, plugins = plugins), MessagesNavigator {
6968
private val presenter = presenterFactory.create(this)
7069
private val callbacks = plugins<Callback>()
@@ -124,7 +123,8 @@ class MessagesNode @AssistedInject constructor(
124123
}
125124

126125
private fun onLinkClick(
127-
context: Context,
126+
activity: Activity,
127+
darkTheme: Boolean,
128128
url: String,
129129
eventSink: (TimelineEvents) -> Unit,
130130
) {
@@ -135,16 +135,20 @@ class MessagesNode @AssistedInject constructor(
135135
callbacks.forEach { it.onUserDataClick(permalink.userId) }
136136
}
137137
is PermalinkData.RoomLink -> {
138-
handleRoomLinkClick(permalink, eventSink)
138+
handleRoomLinkClick(activity, permalink, eventSink)
139139
}
140140
is PermalinkData.FallbackLink,
141141
is PermalinkData.RoomEmailInviteLink -> {
142-
context.openUrlInExternalApp(url)
142+
activity.openUrlInChromeCustomTab(null, darkTheme, url)
143143
}
144144
}
145145
}
146146

147-
private fun handleRoomLinkClick(roomLink: PermalinkData.RoomLink, eventSink: (TimelineEvents) -> Unit) {
147+
private fun handleRoomLinkClick(
148+
context: Context,
149+
roomLink: PermalinkData.RoomLink,
150+
eventSink: (TimelineEvents) -> Unit,
151+
) {
148152
if (room.matches(roomLink.roomIdOrAlias)) {
149153
val eventId = roomLink.eventId
150154
if (eventId != null) {
@@ -192,7 +196,8 @@ class MessagesNode @AssistedInject constructor(
192196

193197
@Composable
194198
override fun View(modifier: Modifier) {
195-
val context = LocalContext.current
199+
val activity = LocalContext.current as Activity
200+
val isDark = ElementTheme.isLightTheme.not()
196201
CompositionLocalProvider(
197202
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
198203
) {
@@ -210,7 +215,7 @@ class MessagesNode @AssistedInject constructor(
210215
onEventClick = this::onEventClick,
211216
onPreviewAttachments = this::onPreviewAttachments,
212217
onUserDataClick = this::onUserDataClick,
213-
onLinkClick = { onLinkClick(context, it, state.timelineState.eventSink) },
218+
onLinkClick = { url -> onLinkClick(activity, isDark, url, state.timelineState.eventSink) },
214219
onSendLocationClick = this::onSendLocationClick,
215220
onCreatePollClick = this::onCreatePollClick,
216221
onJoinCallClick = this::onJoinCallClick,

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
3131
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
3232
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
3333
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
34+
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
3435
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
3536
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
3637
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
@@ -91,6 +92,7 @@ class MessagesPresenter @AssistedInject constructor(
9192
private val voiceMessageComposerPresenter: Presenter<VoiceMessageComposerState>,
9293
timelinePresenterFactory: TimelinePresenter.Factory,
9394
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
95+
private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
9496
private val actionListPresenterFactory: ActionListPresenter.Factory,
9597
private val customReactionPresenter: Presenter<CustomReactionState>,
9698
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
@@ -125,6 +127,7 @@ class MessagesPresenter @AssistedInject constructor(
125127
val voiceMessageComposerState = voiceMessageComposerPresenter.present()
126128
val timelineState = timelinePresenter.present()
127129
val timelineProtectionState = timelineProtectionPresenter.present()
130+
val identityChangeState = identityChangeStatePresenter.present()
128131
val actionListState = actionListPresenter.present()
129132
val customReactionState = customReactionPresenter.present()
130133
val reactionSummaryState = reactionSummaryPresenter.present()
@@ -217,6 +220,7 @@ class MessagesPresenter @AssistedInject constructor(
217220
voiceMessageComposerState = voiceMessageComposerState,
218221
timelineState = timelineState,
219222
timelineProtectionState = timelineProtectionState,
223+
identityChangeState = identityChangeState,
220224
actionListState = actionListState,
221225
customReactionState = customReactionState,
222226
reactionSummaryState = reactionSummaryState,

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package io.element.android.features.messages.impl
99

1010
import androidx.compose.runtime.Immutable
1111
import io.element.android.features.messages.impl.actionlist.ActionListState
12+
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
1213
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
1314
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
1415
import io.element.android.features.messages.impl.timeline.TimelineState
@@ -34,6 +35,7 @@ data class MessagesState(
3435
val voiceMessageComposerState: VoiceMessageComposerState,
3536
val timelineState: TimelineState,
3637
val timelineProtectionState: TimelineProtectionState,
38+
val identityChangeState: IdentityChangeState,
3739
val actionListState: ActionListState,
3840
val customReactionState: CustomReactionState,
3941
val reactionSummaryState: ReactionSummaryState,

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ package io.element.android.features.messages.impl
1010
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
1111
import io.element.android.features.messages.impl.actionlist.ActionListState
1212
import io.element.android.features.messages.impl.actionlist.anActionListState
13+
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
14+
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
1315
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
1416
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
1517
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
@@ -106,6 +108,7 @@ fun aMessagesState(
106108
focusedEventIndex = 2,
107109
),
108110
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
111+
identityChangeState: IdentityChangeState = anIdentityChangeState(),
109112
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
110113
actionListState: ActionListState = anActionListState(),
111114
customReactionState: CustomReactionState = aCustomReactionState(),
@@ -125,6 +128,7 @@ fun aMessagesState(
125128
composerState = composerState,
126129
voiceMessageComposerState = voiceMessageComposerState,
127130
timelineProtectionState = timelineProtectionState,
131+
identityChangeState = identityChangeState,
128132
timelineState = timelineState,
129133
readReceiptBottomSheetState = readReceiptBottomSheetState,
130134
actionListState = actionListState,

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
5757
import io.element.android.features.messages.impl.actionlist.ActionListView
5858
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
5959
import io.element.android.features.messages.impl.attachments.Attachment
60+
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView
6061
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
6162
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
6263
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
@@ -103,6 +104,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
103104
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
104105
import io.element.android.libraries.matrix.api.core.EventId
105106
import io.element.android.libraries.matrix.api.core.UserId
107+
import io.element.android.libraries.textcomposer.model.TextEditorState
106108
import io.element.android.libraries.ui.strings.CommonStrings
107109
import kotlinx.collections.immutable.ImmutableList
108110
import timber.log.Timber
@@ -415,6 +417,7 @@ private fun MessagesViewContent(
415417
MessagesViewComposerBottomSheetContents(
416418
subcomposing = subcomposing,
417419
state = state,
420+
onLinkClick = onLinkClick,
418421
)
419422
},
420423
sheetContentKey = sheetResizeContentKey.intValue,
@@ -428,6 +431,7 @@ private fun MessagesViewContent(
428431
private fun MessagesViewComposerBottomSheetContents(
429432
subcomposing: Boolean,
430433
state: MessagesState,
434+
onLinkClick: (String) -> Unit,
431435
) {
432436
if (state.userEventPermissions.canSendMessage) {
433437
Column(modifier = Modifier.fillMaxWidth()) {
@@ -448,6 +452,14 @@ private fun MessagesViewComposerBottomSheetContents(
448452
state.composerState.eventSink(MessageComposerEvents.InsertSuggestion(it))
449453
}
450454
)
455+
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
456+
if (state.composerState.suggestions.isEmpty() &&
457+
state.composerState.textEditorState is TextEditorState.Markdown) {
458+
IdentityChangeStateView(
459+
state = state.identityChangeState,
460+
onLinkClick = onLinkClick,
461+
)
462+
}
451463
MessageComposerView(
452464
state = state.composerState,
453465
voiceMessageState = state.voiceMessageComposerState,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*
2+
* Copyright 2024 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only
5+
* Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.messages.impl.crypto.identity
9+
10+
import io.element.android.libraries.matrix.api.core.UserId
11+
12+
sealed interface IdentityChangeEvent {
13+
data class Submit(val userId: UserId) : IdentityChangeEvent
14+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright 2024 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only
5+
* Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.messages.impl.crypto.identity
9+
10+
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
11+
import kotlinx.collections.immutable.ImmutableList
12+
13+
data class IdentityChangeState(
14+
val roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange>,
15+
val eventSink: (IdentityChangeEvent) -> Unit,
16+
)
17+
18+
data class RoomMemberIdentityStateChange(
19+
val identityRoomMember: IdentityRoomMember,
20+
val identityState: IdentityState,
21+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Copyright 2024 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only
5+
* Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.messages.impl.crypto.identity
9+
10+
import androidx.compose.runtime.Composable
11+
import androidx.compose.runtime.ProduceStateScope
12+
import androidx.compose.runtime.getValue
13+
import androidx.compose.runtime.produceState
14+
import androidx.compose.runtime.rememberCoroutineScope
15+
import io.element.android.libraries.architecture.Presenter
16+
import io.element.android.libraries.designsystem.components.avatar.AvatarData
17+
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
18+
import io.element.android.libraries.matrix.api.core.UserId
19+
import io.element.android.libraries.matrix.api.encryption.EncryptionService
20+
import io.element.android.libraries.matrix.api.room.MatrixRoom
21+
import io.element.android.libraries.matrix.api.room.RoomMember
22+
import io.element.android.libraries.matrix.api.room.roomMembers
23+
import io.element.android.libraries.matrix.ui.model.getAvatarData
24+
import kotlinx.collections.immutable.PersistentList
25+
import kotlinx.collections.immutable.persistentListOf
26+
import kotlinx.collections.immutable.toPersistentList
27+
import kotlinx.coroutines.CoroutineScope
28+
import kotlinx.coroutines.flow.combine
29+
import kotlinx.coroutines.flow.distinctUntilChanged
30+
import kotlinx.coroutines.flow.launchIn
31+
import kotlinx.coroutines.flow.onEach
32+
import kotlinx.coroutines.launch
33+
import timber.log.Timber
34+
import javax.inject.Inject
35+
36+
class IdentityChangeStatePresenter @Inject constructor(
37+
private val room: MatrixRoom,
38+
private val encryptionService: EncryptionService,
39+
) : Presenter<IdentityChangeState> {
40+
@Composable
41+
override fun present(): IdentityChangeState {
42+
val coroutineScope = rememberCoroutineScope()
43+
val roomMemberIdentityStateChange by produceState(persistentListOf()) {
44+
observeRoomMemberIdentityStateChange()
45+
}
46+
47+
fun handleEvent(event: IdentityChangeEvent) {
48+
when (event) {
49+
is IdentityChangeEvent.Submit -> coroutineScope.pinUserIdentity(event.userId)
50+
}
51+
}
52+
53+
return IdentityChangeState(
54+
roomMemberIdentityStateChanges = roomMemberIdentityStateChange,
55+
eventSink = ::handleEvent,
56+
)
57+
}
58+
59+
private fun ProduceStateScope<PersistentList<RoomMemberIdentityStateChange>>.observeRoomMemberIdentityStateChange() {
60+
combine(room.identityStateChangesFlow, room.membersStateFlow) { identityStateChanges, membersState ->
61+
identityStateChanges.map { identityStateChange ->
62+
val member = membersState.roomMembers()
63+
?.firstOrNull { roomMember -> roomMember.userId == identityStateChange.userId }
64+
?.toIdentityRoomMember()
65+
?: createDefaultRoomMemberForIdentityChange(identityStateChange.userId)
66+
RoomMemberIdentityStateChange(
67+
identityRoomMember = member,
68+
identityState = identityStateChange.identityState,
69+
)
70+
}
71+
}
72+
.distinctUntilChanged()
73+
.onEach { roomMemberIdentityStateChanges ->
74+
value = roomMemberIdentityStateChanges.toPersistentList()
75+
}
76+
.launchIn(this)
77+
}
78+
79+
private fun CoroutineScope.pinUserIdentity(userId: UserId) = launch {
80+
encryptionService.pinUserIdentity(userId)
81+
.onFailure {
82+
Timber.e(it, "Failed to pin identity for user $userId")
83+
}
84+
}
85+
}
86+
87+
private fun RoomMember.toIdentityRoomMember() = IdentityRoomMember(
88+
userId = userId,
89+
disambiguatedDisplayName = disambiguatedDisplayName,
90+
avatarData = getAvatarData(AvatarSize.ComposerAlert),
91+
)
92+
93+
private fun createDefaultRoomMemberForIdentityChange(userId: UserId) = IdentityRoomMember(
94+
userId = userId,
95+
disambiguatedDisplayName = userId.value,
96+
avatarData = AvatarData(
97+
id = userId.value,
98+
name = null,
99+
url = null,
100+
size = AvatarSize.ComposerAlert,
101+
),
102+
)

0 commit comments

Comments
 (0)