Skip to content

Commit 68c1d30

Browse files
bmartyElementBot
andauthored
Check link click (#4463)
* Warn when opening a suspicious link. Upgrade RTE to 2.38.3 * Update screenshots * Add tests on LinkPresenter and LinkView. * Format file --------- Co-authored-by: ElementBot <[email protected]>
1 parent f3193d9 commit 68c1d30

40 files changed

+658
-39
lines changed

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
@@ -32,6 +32,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
3232
import io.element.android.features.messages.impl.actionlist.ActionListState
3333
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
3434
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
35+
import io.element.android.features.messages.impl.link.LinkState
3536
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
3637
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
3738
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
@@ -96,6 +97,7 @@ class MessagesPresenter @AssistedInject constructor(
9697
@Assisted private val timelinePresenter: Presenter<TimelineState>,
9798
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
9899
private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
100+
private val linkPresenter: Presenter<LinkState>,
99101
@Assisted private val actionListPresenter: Presenter<ActionListState>,
100102
private val customReactionPresenter: Presenter<CustomReactionState>,
101103
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
@@ -136,6 +138,7 @@ class MessagesPresenter @AssistedInject constructor(
136138
val timelineProtectionState = timelineProtectionPresenter.present()
137139
val identityChangeState = identityChangeStatePresenter.present()
138140
val actionListState = actionListPresenter.present()
141+
val linkState = linkPresenter.present()
139142
val customReactionState = customReactionPresenter.present()
140143
val reactionSummaryState = reactionSummaryPresenter.present()
141144
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
@@ -245,6 +248,7 @@ class MessagesPresenter @AssistedInject constructor(
245248
timelineState = timelineState,
246249
timelineProtectionState = timelineProtectionState,
247250
identityChangeState = identityChangeState,
251+
linkState = linkState,
248252
actionListState = actionListState,
249253
customReactionState = customReactionState,
250254
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
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl
1010
import androidx.compose.runtime.Immutable
1111
import io.element.android.features.messages.impl.actionlist.ActionListState
1212
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
13+
import io.element.android.features.messages.impl.link.LinkState
1314
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
1415
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
1516
import io.element.android.features.messages.impl.timeline.TimelineState
@@ -38,6 +39,7 @@ data class MessagesState(
3839
val timelineState: TimelineState,
3940
val timelineProtectionState: TimelineProtectionState,
4041
val identityChangeState: IdentityChangeState,
42+
val linkState: LinkState,
4143
val actionListState: ActionListState,
4244
val customReactionState: CustomReactionState,
4345
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
@@ -12,6 +12,8 @@ import io.element.android.features.messages.impl.actionlist.ActionListState
1212
import io.element.android.features.messages.impl.actionlist.anActionListState
1313
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
1414
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
15+
import io.element.android.features.messages.impl.link.LinkState
16+
import io.element.android.features.messages.impl.link.aLinkState
1517
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
1618
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
1719
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
@@ -103,6 +105,7 @@ fun aMessagesState(
103105
),
104106
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
105107
identityChangeState: IdentityChangeState = anIdentityChangeState(),
108+
linkState: LinkState = aLinkState(),
106109
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
107110
actionListState: ActionListState = anActionListState(),
108111
customReactionState: CustomReactionState = aCustomReactionState(),
@@ -124,6 +127,7 @@ fun aMessagesState(
124127
voiceMessageComposerState = voiceMessageComposerState,
125128
timelineProtectionState = timelineProtectionState,
126129
identityChangeState = identityChangeState,
130+
linkState = linkState,
127131
timelineState = timelineState,
128132
readReceiptBottomSheetState = readReceiptBottomSheetState,
129133
actionListState = actionListState,

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

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
5656
import io.element.android.features.messages.impl.actionlist.ActionListView
5757
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
5858
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView
59+
import io.element.android.features.messages.impl.link.LinkEvents
60+
import io.element.android.features.messages.impl.link.LinkView
5961
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
6062
import io.element.android.features.messages.impl.messagecomposer.DisabledComposerView
6163
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
@@ -104,6 +106,7 @@ import io.element.android.libraries.matrix.api.core.UserId
104106
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
105107
import io.element.android.libraries.textcomposer.model.TextEditorState
106108
import io.element.android.libraries.ui.strings.CommonStrings
109+
import io.element.android.wysiwyg.link.Link
107110
import kotlinx.collections.immutable.ImmutableList
108111
import timber.log.Timber
109112
import kotlin.random.Random
@@ -207,7 +210,14 @@ fun MessagesView(
207210
onContentClick = ::onContentClick,
208211
onMessageLongClick = ::onMessageLongClick,
209212
onUserDataClick = { hidingKeyboard { onUserDataClick(it) } },
210-
onLinkClick = onLinkClick,
213+
onLinkClick = { link, customTab ->
214+
if (customTab) {
215+
onLinkClick(link.url, true)
216+
// Do not check those links, they are internal link only
217+
} else {
218+
state.linkState.eventSink(LinkEvents.OnLinkClick(link))
219+
}
220+
},
211221
onReactionClick = ::onEmojiReactionClick,
212222
onReactionLongClick = ::onEmojiReactionLongClick,
213223
onMoreReactionsClick = ::onMoreReactionsClick,
@@ -258,6 +268,12 @@ fun MessagesView(
258268
onUserDataClick = onUserDataClick,
259269
)
260270
ReinviteDialog(state = state)
271+
LinkView(
272+
onLinkValid = { link ->
273+
onLinkClick(link.url, false)
274+
},
275+
state = state.linkState,
276+
)
261277
}
262278

263279
@Composable
@@ -279,7 +295,7 @@ private fun MessagesViewContent(
279295
state: MessagesState,
280296
onContentClick: (TimelineItem.Event) -> Unit,
281297
onUserDataClick: (UserId) -> Unit,
282-
onLinkClick: (String, Boolean) -> Unit,
298+
onLinkClick: (Link, Boolean) -> Unit,
283299
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
284300
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
285301
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
@@ -353,7 +369,7 @@ private fun MessagesViewContent(
353369
state = state.timelineState,
354370
timelineProtectionState = state.timelineProtectionState,
355371
onUserDataClick = onUserDataClick,
356-
onLinkClick = { url -> onLinkClick(url, false) },
372+
onLinkClick = { link -> onLinkClick(link, false) },
357373
onContentClick = onContentClick,
358374
onMessageLongClick = onMessageLongClick,
359375
onSwipeToReply = onSwipeToReply,
@@ -388,7 +404,7 @@ private fun MessagesViewContent(
388404
MessagesViewComposerBottomSheetContents(
389405
subcomposing = subcomposing,
390406
state = state,
391-
onLinkClick = onLinkClick,
407+
onLinkClick = { url, customTab -> onLinkClick(Link(url), customTab) },
392408
)
393409
},
394410
sheetContentKey = sheetResizeContentKey.intValue,

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import io.element.android.features.messages.impl.crypto.identity.IdentityChangeS
1414
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter
1515
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter
1616
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
17+
import io.element.android.features.messages.impl.link.LinkPresenter
18+
import io.element.android.features.messages.impl.link.LinkState
1719
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter
1820
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
1921
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
@@ -46,6 +48,9 @@ interface MessagesModule {
4648
@Binds
4749
fun bindTimelineProtectionPresenter(presenter: TimelineProtectionPresenter): Presenter<TimelineProtectionState>
4850

51+
@Binds
52+
fun bindLinkPresenter(presenter: LinkPresenter): Presenter<LinkState>
53+
4954
@Binds
5055
fun bindVoiceMessageComposerPresenter(presenter: VoiceMessageComposerPresenter): Presenter<VoiceMessageComposerState>
5156

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.messages.impl.link
9+
10+
import io.element.android.libraries.architecture.AsyncAction
11+
import io.element.android.wysiwyg.link.Link
12+
13+
data class ConfirmingLinkClick(
14+
val link: Link,
15+
) : AsyncAction.Confirming
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.messages.impl.link
9+
10+
import com.squareup.anvil.annotations.ContributesBinding
11+
import io.element.android.libraries.core.data.tryOrNull
12+
import io.element.android.libraries.core.extensions.containsRtLOverride
13+
import io.element.android.libraries.di.AppScope
14+
import io.element.android.wysiwyg.link.Link
15+
import java.net.URI
16+
import javax.inject.Inject
17+
18+
interface LinkChecker {
19+
fun isSafe(link: Link): Boolean
20+
}
21+
22+
@ContributesBinding(AppScope::class)
23+
class DefaultLinkChecker @Inject constructor() : LinkChecker {
24+
override fun isSafe(link: Link): Boolean {
25+
return if (link.url.containsRtLOverride()) {
26+
false
27+
} else {
28+
val textUrl = tryOrNull { URI(link.text).toURL() }
29+
val urlUrl = tryOrNull { URI(link.url).toURL() }
30+
if (textUrl == null || urlUrl == null) {
31+
// The text is not a Url, or the url is not valid
32+
true
33+
} else {
34+
// the hosts must match
35+
textUrl.host == urlUrl.host
36+
}
37+
}
38+
}
39+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.messages.impl.link
9+
10+
import io.element.android.wysiwyg.link.Link
11+
12+
sealed interface LinkEvents {
13+
data class OnLinkClick(val link: Link) : LinkEvents
14+
data object Confirm : LinkEvents
15+
data object Cancel : LinkEvents
16+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.messages.impl.link
9+
10+
import androidx.compose.runtime.Composable
11+
import androidx.compose.runtime.MutableState
12+
import androidx.compose.runtime.mutableStateOf
13+
import androidx.compose.runtime.remember
14+
import io.element.android.libraries.architecture.AsyncAction
15+
import io.element.android.libraries.architecture.Presenter
16+
import io.element.android.wysiwyg.link.Link
17+
import javax.inject.Inject
18+
19+
class LinkPresenter @Inject constructor(
20+
private val linkChecker: LinkChecker,
21+
) : Presenter<LinkState> {
22+
@Composable
23+
override fun present(): LinkState {
24+
val linkClick: MutableState<AsyncAction<Link>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
25+
26+
fun handleEvents(linkEvents: LinkEvents) {
27+
when (linkEvents) {
28+
is LinkEvents.OnLinkClick -> {
29+
linkClick.value = AsyncAction.Loading
30+
val result = linkChecker.isSafe(linkEvents.link)
31+
if (result) {
32+
linkClick.value = AsyncAction.Success(linkEvents.link)
33+
} else {
34+
// Confirm first
35+
linkClick.value = ConfirmingLinkClick(linkEvents.link)
36+
}
37+
}
38+
LinkEvents.Confirm -> {
39+
linkClick.value = (linkClick.value as? ConfirmingLinkClick)
40+
?.let { AsyncAction.Success(it.link) }
41+
?: AsyncAction.Uninitialized
42+
}
43+
LinkEvents.Cancel -> {
44+
linkClick.value = AsyncAction.Uninitialized
45+
}
46+
}
47+
}
48+
return LinkState(
49+
linkClick = linkClick.value,
50+
eventSink = ::handleEvents,
51+
)
52+
}
53+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.messages.impl.link
9+
10+
import io.element.android.libraries.architecture.AsyncAction
11+
import io.element.android.wysiwyg.link.Link
12+
13+
data class LinkState(
14+
val linkClick: AsyncAction<Link>,
15+
val eventSink: (LinkEvents) -> Unit,
16+
)

0 commit comments

Comments
 (0)