From 9ab61440019e7e4223ce69c7f853c46705f3a5db Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 25 Mar 2025 18:17:20 +0100 Subject: [PATCH] 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 --- .../messages/impl/MessagesPresenter.kt | 4 + .../features/messages/impl/MessagesState.kt | 2 + .../messages/impl/MessagesStateProvider.kt | 4 + .../features/messages/impl/MessagesView.kt | 24 ++++- .../messages/impl/di/MessagesModule.kt | 5 + .../messages/impl/link/ConfirmingLinkClick.kt | 15 +++ .../messages/impl/link/LinkChecker.kt | 39 ++++++++ .../features/messages/impl/link/LinkEvents.kt | 16 ++++ .../messages/impl/link/LinkPresenter.kt | 53 ++++++++++ .../features/messages/impl/link/LinkState.kt | 16 ++++ .../messages/impl/link/LinkStateProvider.kt | 35 +++++++ .../features/messages/impl/link/LinkView.kt | 73 ++++++++++++++ .../pinned/list/PinnedMessagesListNode.kt | 4 +- .../list/PinnedMessagesListPresenter.kt | 6 ++ .../pinned/list/PinnedMessagesListState.kt | 2 + .../list/PinnedMessagesListStateProvider.kt | 4 + .../pinned/list/PinnedMessagesListView.kt | 31 ++++-- .../messages/impl/timeline/TimelineView.kt | 7 +- .../components/TimelineItemEventRow.kt | 5 +- .../TimelineItemGroupedEventsRow.kt | 9 +- .../timeline/components/TimelineItemRow.kt | 5 +- .../event/TimelineItemEventContentView.kt | 5 +- .../components/event/TimelineItemImageView.kt | 5 +- .../components/event/TimelineItemTextView.kt | 5 +- .../components/event/TimelineItemVideoView.kt | 5 +- .../messages/impl/MessagesPresenterTest.kt | 2 + .../impl/link/DefaultLinkCheckerTest.kt | 51 ++++++++++ .../messages/impl/link/FakeLinkChecker.kt | 17 ++++ .../messages/impl/link/LinkPresenterTest.kt | 96 +++++++++++++++++++ .../messages/impl/link/LinkViewTest.kt | 89 +++++++++++++++++ .../list/PinnedMessagesListPresenterTest.kt | 2 + .../pinned/list/PinnedMessagesListViewTest.kt | 5 +- .../impl/timeline/TimelineViewTest.kt | 3 +- gradle/libs.versions.toml | 2 +- .../core/extensions/BasicExtensions.kt | 9 ++ .../core/extensions/BasicExtensionsTest.kt | 30 ++++++ ...s.messages.impl.link_LinkView_Day_0_en.png | 3 + ...s.messages.impl.link_LinkView_Day_1_en.png | 3 + ...messages.impl.link_LinkView_Night_0_en.png | 3 + ...messages.impl.link_LinkView_Night_1_en.png | 3 + 40 files changed, 658 insertions(+), 39 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/ConfirmingLinkClick.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkChecker.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkEvents.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkPresenter.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkState.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkStateProvider.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkView.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/DefaultLinkCheckerTest.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/FakeLinkChecker.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/LinkPresenterTest.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/LinkViewTest.kt create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.link_LinkView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.link_LinkView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.link_LinkView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.link_LinkView_Night_1_en.png diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 0ace1a9f47..405bc2c536 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -32,6 +32,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.model.TimelineItemAction import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState +import io.element.android.features.messages.impl.link.LinkState 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 @@ -96,6 +97,7 @@ class MessagesPresenter @AssistedInject constructor( @Assisted private val timelinePresenter: Presenter, private val timelineProtectionPresenter: Presenter, private val identityChangeStatePresenter: Presenter, + private val linkPresenter: Presenter, @Assisted private val actionListPresenter: Presenter, private val customReactionPresenter: Presenter, private val reactionSummaryPresenter: Presenter, @@ -136,6 +138,7 @@ class MessagesPresenter @AssistedInject constructor( val timelineProtectionState = timelineProtectionPresenter.present() val identityChangeState = identityChangeStatePresenter.present() val actionListState = actionListPresenter.present() + val linkState = linkPresenter.present() val customReactionState = customReactionPresenter.present() val reactionSummaryState = reactionSummaryPresenter.present() val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present() @@ -245,6 +248,7 @@ class MessagesPresenter @AssistedInject constructor( timelineState = timelineState, timelineProtectionState = timelineProtectionState, identityChangeState = identityChangeState, + linkState = linkState, actionListState = actionListState, customReactionState = customReactionState, reactionSummaryState = reactionSummaryState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 4e6fe3cc94..2a6889be41 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -10,6 +10,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.link.LinkState 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 @@ -38,6 +39,7 @@ data class MessagesState( val timelineState: TimelineState, val timelineProtectionState: TimelineProtectionState, val identityChangeState: IdentityChangeState, + val linkState: LinkState, val actionListState: ActionListState, val customReactionState: CustomReactionState, val reactionSummaryState: ReactionSummaryState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index b5b87b2f87..12c6d607b0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -12,6 +12,8 @@ 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.link.LinkState +import io.element.android.features.messages.impl.link.aLinkState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState @@ -103,6 +105,7 @@ fun aMessagesState( ), timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(), identityChangeState: IdentityChangeState = anIdentityChangeState(), + linkState: LinkState = aLinkState(), readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(), actionListState: ActionListState = anActionListState(), customReactionState: CustomReactionState = aCustomReactionState(), @@ -124,6 +127,7 @@ fun aMessagesState( voiceMessageComposerState = voiceMessageComposerState, timelineProtectionState = timelineProtectionState, identityChangeState = identityChangeState, + linkState = linkState, timelineState = timelineState, readReceiptBottomSheetState = readReceiptBottomSheetState, actionListState = actionListState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 5d6ad6fcbe..b8fb44dca2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -56,6 +56,8 @@ 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.crypto.identity.IdentityChangeStateView +import io.element.android.features.messages.impl.link.LinkEvents +import io.element.android.features.messages.impl.link.LinkView import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet import io.element.android.features.messages.impl.messagecomposer.DisabledComposerView import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents @@ -104,6 +106,7 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.wysiwyg.link.Link import kotlinx.collections.immutable.ImmutableList import timber.log.Timber import kotlin.random.Random @@ -207,7 +210,14 @@ fun MessagesView( onContentClick = ::onContentClick, onMessageLongClick = ::onMessageLongClick, onUserDataClick = { hidingKeyboard { onUserDataClick(it) } }, - onLinkClick = onLinkClick, + onLinkClick = { link, customTab -> + if (customTab) { + onLinkClick(link.url, true) + // Do not check those links, they are internal link only + } else { + state.linkState.eventSink(LinkEvents.OnLinkClick(link)) + } + }, onReactionClick = ::onEmojiReactionClick, onReactionLongClick = ::onEmojiReactionLongClick, onMoreReactionsClick = ::onMoreReactionsClick, @@ -258,6 +268,12 @@ fun MessagesView( onUserDataClick = onUserDataClick, ) ReinviteDialog(state = state) + LinkView( + onLinkValid = { link -> + onLinkClick(link.url, false) + }, + state = state.linkState, + ) } @Composable @@ -279,7 +295,7 @@ private fun MessagesViewContent( state: MessagesState, onContentClick: (TimelineItem.Event) -> Unit, onUserDataClick: (UserId) -> Unit, - onLinkClick: (String, Boolean) -> Unit, + onLinkClick: (Link, Boolean) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit, @@ -353,7 +369,7 @@ private fun MessagesViewContent( state = state.timelineState, timelineProtectionState = state.timelineProtectionState, onUserDataClick = onUserDataClick, - onLinkClick = { url -> onLinkClick(url, false) }, + onLinkClick = { link -> onLinkClick(link, false) }, onContentClick = onContentClick, onMessageLongClick = onMessageLongClick, onSwipeToReply = onSwipeToReply, @@ -388,7 +404,7 @@ private fun MessagesViewContent( MessagesViewComposerBottomSheetContents( subcomposing = subcomposing, state = state, - onLinkClick = onLinkClick, + onLinkClick = { url, customTab -> onLinkClick(Link(url), customTab) }, ) }, sheetContentKey = sheetResizeContentKey.intValue, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt index f06a743c6d..ed84ef9df0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt @@ -14,6 +14,8 @@ import io.element.android.features.messages.impl.crypto.identity.IdentityChangeS 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.link.LinkPresenter +import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter @@ -46,6 +48,9 @@ interface MessagesModule { @Binds fun bindTimelineProtectionPresenter(presenter: TimelineProtectionPresenter): Presenter + @Binds + fun bindLinkPresenter(presenter: LinkPresenter): Presenter + @Binds fun bindVoiceMessageComposerPresenter(presenter: VoiceMessageComposerPresenter): Presenter diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/ConfirmingLinkClick.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/ConfirmingLinkClick.kt new file mode 100644 index 0000000000..612b8a231b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/ConfirmingLinkClick.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.wysiwyg.link.Link + +data class ConfirmingLinkClick( + val link: Link, +) : AsyncAction.Confirming diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkChecker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkChecker.kt new file mode 100644 index 0000000000..6bf24642bc --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkChecker.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.core.extensions.containsRtLOverride +import io.element.android.libraries.di.AppScope +import io.element.android.wysiwyg.link.Link +import java.net.URI +import javax.inject.Inject + +interface LinkChecker { + fun isSafe(link: Link): Boolean +} + +@ContributesBinding(AppScope::class) +class DefaultLinkChecker @Inject constructor() : LinkChecker { + override fun isSafe(link: Link): Boolean { + return if (link.url.containsRtLOverride()) { + false + } else { + val textUrl = tryOrNull { URI(link.text).toURL() } + val urlUrl = tryOrNull { URI(link.url).toURL() } + if (textUrl == null || urlUrl == null) { + // The text is not a Url, or the url is not valid + true + } else { + // the hosts must match + textUrl.host == urlUrl.host + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkEvents.kt new file mode 100644 index 0000000000..717b68f57f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import io.element.android.wysiwyg.link.Link + +sealed interface LinkEvents { + data class OnLinkClick(val link: Link) : LinkEvents + data object Confirm : LinkEvents + data object Cancel : LinkEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkPresenter.kt new file mode 100644 index 0000000000..3259bdf8f8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkPresenter.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.wysiwyg.link.Link +import javax.inject.Inject + +class LinkPresenter @Inject constructor( + private val linkChecker: LinkChecker, +) : Presenter { + @Composable + override fun present(): LinkState { + val linkClick: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + + fun handleEvents(linkEvents: LinkEvents) { + when (linkEvents) { + is LinkEvents.OnLinkClick -> { + linkClick.value = AsyncAction.Loading + val result = linkChecker.isSafe(linkEvents.link) + if (result) { + linkClick.value = AsyncAction.Success(linkEvents.link) + } else { + // Confirm first + linkClick.value = ConfirmingLinkClick(linkEvents.link) + } + } + LinkEvents.Confirm -> { + linkClick.value = (linkClick.value as? ConfirmingLinkClick) + ?.let { AsyncAction.Success(it.link) } + ?: AsyncAction.Uninitialized + } + LinkEvents.Cancel -> { + linkClick.value = AsyncAction.Uninitialized + } + } + } + return LinkState( + linkClick = linkClick.value, + eventSink = ::handleEvents, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkState.kt new file mode 100644 index 0000000000..0986378096 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkState.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.wysiwyg.link.Link + +data class LinkState( + val linkClick: AsyncAction, + val eventSink: (LinkEvents) -> Unit, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkStateProvider.kt new file mode 100644 index 0000000000..478f8bf322 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkStateProvider.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.wysiwyg.link.Link + +open class LinkStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aLinkState(), + aLinkState( + linkClick = ConfirmingLinkClick( + Link( + url = "https://evil.io", + text = "https://element.io" + ), + ), + ), + ) +} + +fun aLinkState( + linkClick: AsyncAction = AsyncAction.Uninitialized, + eventSink: (LinkEvents) -> Unit = {}, +) = LinkState( + linkClick = linkClick, + eventSink = eventSink, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkView.kt new file mode 100644 index 0000000000..53306c8e99 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkView.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.core.extensions.ensureEndsLeftToRight +import io.element.android.libraries.core.extensions.filterDirectionOverrides +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.wysiwyg.link.Link + +@Composable +fun LinkView( + state: LinkState, + onLinkValid: (Link) -> Unit, + modifier: Modifier = Modifier, +) { + when (state.linkClick) { + AsyncAction.Uninitialized, + AsyncAction.Loading, + is AsyncAction.Failure -> Unit + is AsyncAction.Confirming -> { + if (state.linkClick is ConfirmingLinkClick) { + ConfirmationDialog( + modifier = modifier, + title = stringResource(CommonStrings.dialog_confirm_link_title), + content = stringResource( + CommonStrings.dialog_confirm_link_message, + state.linkClick.link.text.ensureEndsLeftToRight(), + state.linkClick.link.url.filterDirectionOverrides(), + ), + submitText = stringResource(CommonStrings.action_continue), + onSubmitClick = { + state.eventSink(LinkEvents.Confirm) + }, + onDismiss = { + state.eventSink(LinkEvents.Cancel) + }, + ) + } + } + is AsyncAction.Success -> { + val latestOnLinkValid by rememberUpdatedState(onLinkValid) + LaunchedEffect(state.linkClick.data) { + latestOnLinkValid(state.linkClick.data) + state.eventSink(LinkEvents.Cancel) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun LinkViewPreview(@PreviewParameter(LinkStateProvider::class) state: LinkState) = ElementPreview { + LinkView( + state = state, + onLinkValid = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt index c3e936c549..9827698f99 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt @@ -109,13 +109,13 @@ class PinnedMessagesListNode @AssistedInject constructor( onBackClick = ::navigateUp, onEventClick = ::onEventClick, onUserDataClick = ::onUserDataClick, - onLinkClick = { url -> onLinkClick(context, url) }, + onLinkClick = { link -> onLinkClick(context, link.url) }, onLinkLongClick = { view.performHapticFeedback( HapticFeedbackConstants.LONG_PRESS ) context.copyToClipboard( - it, + it.url, context.getString(CommonStrings.common_copied_to_clipboard) ) }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt index 9eed64ca2d..bb88c32f5f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt @@ -25,6 +25,7 @@ import im.vector.app.features.analytics.plan.PinUnpinAction import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory @@ -63,6 +64,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor( timelineItemsFactoryCreator: TimelineItemsFactory.Creator, private val timelineProvider: PinnedEventsTimelineProvider, private val timelineProtectionPresenter: Presenter, + private val linkPresenter: Presenter, private val snackbarDispatcher: SnackbarDispatcher, @Assisted private val actionListPresenter: Presenter, private val appCoroutineScope: CoroutineScope, @@ -106,6 +108,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor( ) } val timelineProtectionState = timelineProtectionPresenter.present() + val linkState = linkPresenter.present() val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val userEventPermissions by userEventPermissions(syncUpdateFlow.value) @@ -127,6 +130,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor( return pinnedMessagesListState( timelineRoomInfo = timelineRoomInfo, timelineProtectionState = timelineProtectionState, + linkState = linkState, userEventPermissions = userEventPermissions, timelineItems = pinnedMessageItems, eventSink = ::handleEvents @@ -223,6 +227,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor( private fun pinnedMessagesListState( timelineRoomInfo: TimelineRoomInfo, timelineProtectionState: TimelineProtectionState, + linkState: LinkState, userEventPermissions: UserEventPermissions, timelineItems: AsyncData>, eventSink: (PinnedMessagesListEvents) -> Unit @@ -238,6 +243,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor( PinnedMessagesListState.Filled( timelineRoomInfo = timelineRoomInfo, timelineProtectionState = timelineProtectionState, + linkState = linkState, userEventPermissions = userEventPermissions, timelineItems = timelineItems.data, actionListState = actionListState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt index feaac1daa4..d702e2d40f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.actionlist.ActionListState +import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState @@ -31,6 +32,7 @@ sealed interface PinnedMessagesListState { val userEventPermissions: UserEventPermissions, val timelineItems: ImmutableList, val actionListState: ActionListState, + val linkState: LinkState, val eventSink: (PinnedMessagesListEvents) -> Unit, ) : PinnedMessagesListState { val loadedPinnedMessagesCount = timelineItems.count { timelineItem -> timelineItem is TimelineItem.Event } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt index e2600e5034..2a9b7a085c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt @@ -11,6 +11,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.messages.impl.UserEventPermissions 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.link.LinkState +import io.element.android.features.messages.impl.link.aLinkState import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator import io.element.android.features.messages.impl.timeline.aTimelineItemEvent @@ -86,6 +88,7 @@ fun anEmptyPinnedMessagesListState() = PinnedMessagesListState.Empty fun aLoadedPinnedMessagesListState( timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(), timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(), + linkState: LinkState = aLinkState(), timelineItems: List = emptyList(), actionListState: ActionListState = anActionListState(), aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT, @@ -93,6 +96,7 @@ fun aLoadedPinnedMessagesListState( ) = PinnedMessagesListState.Filled( timelineRoomInfo = timelineRoomInfo, timelineProtectionState = timelineProtectionState, + linkState = linkState, timelineItems = timelineItems.toImmutableList(), actionListState = actionListState, userEventPermissions = aUserEventPermissions, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index 10db967d3b..c87e4c0ffd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -28,6 +28,8 @@ import io.element.android.compound.tokens.generated.CompoundIcons 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.link.LinkEvents +import io.element.android.features.messages.impl.link.LinkView import io.element.android.features.messages.impl.timeline.components.TimelineItemRow import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData @@ -50,6 +52,7 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.compose.LocalAnalyticsService import io.element.android.services.analyticsproviders.api.trackers.captureInteraction +import io.element.android.wysiwyg.link.Link @Composable fun PinnedMessagesListView( @@ -57,8 +60,8 @@ fun PinnedMessagesListView( onBackClick: () -> Unit, onEventClick: (event: TimelineItem.Event) -> Unit, onUserDataClick: (UserId) -> Unit, - onLinkClick: (String) -> Unit, - onLinkLongClick: (String) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -113,8 +116,8 @@ private fun PinnedMessagesListContent( state: PinnedMessagesListState, onEventClick: (event: TimelineItem.Event) -> Unit, onUserDataClick: (UserId) -> Unit, - onLinkClick: (String) -> Unit, - onLinkLongClick: (String) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, onErrorDismiss: () -> Unit, modifier: Modifier = Modifier, ) { @@ -169,8 +172,8 @@ private fun PinnedMessagesListLoaded( state: PinnedMessagesListState.Filled, onEventClick: (event: TimelineItem.Event) -> Unit, onUserDataClick: (UserId) -> Unit, - onLinkClick: (String) -> Unit, - onLinkLongClick: (String) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, modifier: Modifier = Modifier, ) { fun onActionSelected(timelineItemAction: TimelineItemAction, event: TimelineItem.Event) { @@ -220,7 +223,9 @@ private fun PinnedMessagesListLoaded( isLastOutgoingMessage = false, focusedEventId = null, onUserDataClick = onUserDataClick, - onLinkClick = onLinkClick, + onLinkClick = { link -> + state.linkState.eventSink(LinkEvents.OnLinkClick(link)) + }, onLinkLongClick = onLinkLongClick, onContentClick = onEventClick, onLongClick = ::onMessageLongClick, @@ -238,7 +243,9 @@ private fun PinnedMessagesListLoaded( timelineProtectionState = state.timelineProtectionState, onContentClick = { onEventClick(event) }, onLongClick = { onMessageLongClick(event) }, - onLinkClick = onLinkClick, + onLinkClick = { link -> + state.linkState.eventSink(LinkEvents.OnLinkClick(link)) + }, onLinkLongClick = onLinkLongClick, modifier = contentModifier, onContentLayoutChange = onContentLayoutChange @@ -247,6 +254,10 @@ private fun PinnedMessagesListLoaded( ) } } + LinkView( + state.linkState, + onLinkValid = onLinkClick, + ) } @Composable @@ -254,8 +265,8 @@ private fun TimelineItemEventContentViewWrapper( event: TimelineItem.Event, timelineProtectionState: TimelineProtectionState, onContentClick: () -> Unit, - onLinkClick: (String) -> Unit, - onLinkLongClick: (String) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, onLongClick: (() -> Unit)?, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index b34af8f5a3..7dea7c6696 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -75,6 +75,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.wysiwyg.link.Link import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay @@ -92,7 +93,7 @@ fun TimelineView( state: TimelineState, timelineProtectionState: TimelineProtectionState, onUserDataClick: (UserId) -> Unit, - onLinkClick: (String) -> Unit, + onLinkClick: (Link) -> Unit, onContentClick: (TimelineItem.Event) -> Unit, onMessageLongClick: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit, @@ -134,12 +135,12 @@ fun TimelineView( state.eventSink(TimelineEvents.FocusOnEvent(eventId)) } - fun onLinkLongClick(link: String) { + fun onLinkLongClick(link: Link) { view.performHapticFeedback( HapticFeedbackConstants.LONG_PRESS ) context.copyToClipboard( - link, + link.url, context.getString(CommonStrings.common_copied_to_clipboard) ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 5a65373429..ae17f26898 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -94,6 +94,7 @@ import io.element.android.libraries.matrix.ui.messages.sender.SenderName import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.wysiwyg.link.Link import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.roundToInt @@ -117,8 +118,8 @@ fun TimelineItemEventRow( isHighlighted: Boolean, onEventClick: () -> Unit, onLongClick: () -> Unit, - onLinkClick: (String) -> Unit, - onLinkLongClick: (String) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, onUserDataClick: (UserId) -> Unit, inReplyToClick: (EventId) -> Unit, onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt index 4a1daa7ba1..112b3dbea6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -32,6 +32,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.wysiwyg.link.Link @Composable fun TimelineItemGroupedEventsRow( @@ -45,8 +46,8 @@ fun TimelineItemGroupedEventsRow( onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, onUserDataClick: (UserId) -> Unit, - onLinkClick: (String) -> Unit, - onLinkLongClick: (String) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit, @@ -114,8 +115,8 @@ private fun TimelineItemGroupedEventsRowContent( onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, onUserDataClick: (UserId) -> Unit, - onLinkClick: (String) -> Unit, - onLinkLongClick: (String) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 525cd436e0..8595364f2c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -37,6 +37,7 @@ import io.element.android.libraries.designsystem.theme.LocalBuildMeta import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.wysiwyg.link.Link @Composable internal fun TimelineItemRow( @@ -47,8 +48,8 @@ internal fun TimelineItemRow( timelineProtectionState: TimelineProtectionState, focusedEventId: EventId?, onUserDataClick: (UserId) -> Unit, - onLinkClick: (String) -> Unit, - onLinkLongClick: (String) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, onContentClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 89a7f6d6ba..332f58777c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.voiceplayer.api.VoiceMessageState +import io.element.android.wysiwyg.link.Link @Composable fun TimelineItemEventContentView( @@ -39,8 +40,8 @@ fun TimelineItemEventContentView( onContentClick: (() -> Unit)?, onLongClick: (() -> Unit)?, onShowContentClick: () -> Unit, - onLinkClick: (url: String) -> Unit, - onLinkLongClick: (String) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, modifier: Modifier = Modifier, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt index 2adb2b5f75..b9e512cd9c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt @@ -56,6 +56,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.wysiwyg.compose.EditorStyledText +import io.element.android.wysiwyg.link.Link @OptIn(ExperimentalFoundationApi::class) @Composable @@ -64,8 +65,8 @@ fun TimelineItemImageView( hideMediaContent: Boolean, onContentClick: (() -> Unit)?, onLongClick: (() -> Unit)?, - onLinkClick: (String) -> Unit, - onLinkLongClick: (String) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, onShowContentClick: () -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt index d00c0b58cc..aed72254c6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt @@ -41,12 +41,13 @@ import io.element.android.libraries.textcomposer.mentions.MentionSpan import io.element.android.libraries.textcomposer.mentions.getMentionSpans import io.element.android.libraries.textcomposer.mentions.updateMentionStyles import io.element.android.wysiwyg.compose.EditorStyledText +import io.element.android.wysiwyg.link.Link @Composable fun TimelineItemTextView( content: TimelineItemTextBasedContent, - onLinkClick: (String) -> Unit, - onLinkLongClick: (String) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, modifier: Modifier = Modifier, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, ) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index 1fb410fd97..6855849793 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -64,6 +64,7 @@ import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.wysiwyg.compose.EditorStyledText +import io.element.android.wysiwyg.link.Link @OptIn(ExperimentalFoundationApi::class) @Composable @@ -73,8 +74,8 @@ fun TimelineItemVideoView( onContentClick: (() -> Unit)?, onLongClick: (() -> Unit)?, onShowContentClick: () -> Unit, - onLinkClick: (String) -> Unit, - onLinkLongClick: (String) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 1f7d83401e..ea6dfa0607 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -16,6 +16,7 @@ import io.element.android.features.messages.impl.actionlist.anActionListState 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.link.aLinkState 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.messagecomposer.aMessageComposerState @@ -1159,6 +1160,7 @@ class MessagesPresenterTest { reactionSummaryPresenter = { aReactionSummaryState() }, readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() }, identityChangeStatePresenter = { anIdentityChangeState() }, + linkPresenter = { aLinkState() }, pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() }, roomCallStatePresenter = { aStandByCallState() }, syncService = FakeSyncService(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/DefaultLinkCheckerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/DefaultLinkCheckerTest.kt new file mode 100644 index 0000000000..9604290e54 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/DefaultLinkCheckerTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import com.google.common.truth.Truth.assertThat +import io.element.android.wysiwyg.link.Link +import org.junit.Test + +class DefaultLinkCheckerTest { + private val sut = DefaultLinkChecker() + + @Test + fun `when url and text are identical, the link is safe`() { + assertThat(sut.isSafe(Link("url", "url"))).isTrue() + } + + @Test + fun `when url is not safe, the link is safe`() { + assertThat(sut.isSafe(Link("url", "https://example.org"))).isTrue() + } + + @Test + fun `when text is a url, and url is identical the link is safe`() { + assertThat(sut.isSafe(Link("https://example.org", "https://example.org"))).isTrue() + } + + @Test + fun `when url contains RtL char, the link is not safe`() { + assertThat(sut.isSafe(Link("https://example\u202E.org", "text"))).isFalse() + } + + @Test + fun `when text is not a url, the link is safe`() { + assertThat(sut.isSafe(Link("https://example.org", "url"))).isTrue() + } + + @Test + fun `when text is a url and hosts match, the link is safe`() { + assertThat(sut.isSafe(Link("https://example.org/some/path", "https://example.org"))).isTrue() + } + + @Test + fun `when text is a url and hosts do not match, the link is safe`() { + assertThat(sut.isSafe(Link("https://example.org", "https://evil.org"))).isFalse() + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/FakeLinkChecker.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/FakeLinkChecker.kt new file mode 100644 index 0000000000..a50f9109f4 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/FakeLinkChecker.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.wysiwyg.link.Link + +class FakeLinkChecker( + private val isSafeResult: (Link) -> Boolean = { lambdaError() } +) : LinkChecker { + override fun isSafe(link: Link) = isSafeResult(link) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/LinkPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/LinkPresenterTest.kt new file mode 100644 index 0000000000..87ab37e63d --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/LinkPresenterTest.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +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 io.element.android.wysiwyg.link.Link +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +val aLink = Link(url = "url", text = "text") + +class LinkPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.linkClick).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - safe link case`() = runTest { + val isSafeResult = lambdaRecorder { + true + } + val presenter = createPresenter( + linkChecker = FakeLinkChecker(isSafeResult = isSafeResult) + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.linkClick).isEqualTo(AsyncAction.Uninitialized) + initialState.eventSink(LinkEvents.OnLinkClick(aLink)) + assertThat(awaitItem().linkClick).isEqualTo(AsyncAction.Loading) + val state = awaitItem() + assertThat(state.linkClick).isEqualTo(AsyncAction.Success(aLink)) + isSafeResult.assertions().isCalledOnce().with(value(aLink)) + } + } + + @Test + fun `present - suspicious link case - cancel`() = runTest { + val presenter = createPresenter( + linkChecker = FakeLinkChecker(isSafeResult = { false }) + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.linkClick).isEqualTo(AsyncAction.Uninitialized) + initialState.eventSink(LinkEvents.OnLinkClick(aLink)) + assertThat(awaitItem().linkClick).isEqualTo(AsyncAction.Loading) + val state = awaitItem() + assertThat(state.linkClick).isEqualTo(ConfirmingLinkClick(aLink)) + state.eventSink(LinkEvents.Cancel) + val finalState = awaitItem() + assertThat(finalState.linkClick).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - suspicious link case - confirm`() = runTest { + val presenter = createPresenter( + linkChecker = FakeLinkChecker(isSafeResult = { false }) + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.linkClick).isEqualTo(AsyncAction.Uninitialized) + initialState.eventSink(LinkEvents.OnLinkClick(aLink)) + assertThat(awaitItem().linkClick).isEqualTo(AsyncAction.Loading) + val state = awaitItem() + assertThat(state.linkClick).isEqualTo(ConfirmingLinkClick(aLink)) + state.eventSink(LinkEvents.Confirm) + val finalState = awaitItem() + assertThat(finalState.linkClick).isEqualTo(AsyncAction.Success(aLink)) + } + } + + private fun createPresenter( + linkChecker: LinkChecker = FakeLinkChecker(), + ) = LinkPresenter( + linkChecker = linkChecker, + ) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/LinkViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/LinkViewTest.kt new file mode 100644 index 0000000000..2936cbb31d --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/LinkViewTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.wysiwyg.link.Link +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LinkViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on cancel emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setLinkView( + aLinkState( + linkClick = ConfirmingLinkClick(aLink), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle( + LinkEvents.Cancel + ) + } + + @Test + fun `clicking on continue emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setLinkView( + aLinkState( + linkClick = ConfirmingLinkClick(aLink), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_continue) + eventsRecorder.assertSingle( + LinkEvents.Confirm + ) + } + + @Test + fun `success state invokes the callback and emits the expected event`() { + val eventsRecorder = EventsRecorder() + ensureCalledOnceWithParam(aLink) { callback -> + rule.setLinkView( + aLinkState( + linkClick = AsyncAction.Success(aLink), + eventSink = eventsRecorder, + ), + onLinkValid = callback, + ) + } + eventsRecorder.assertSingle( + LinkEvents.Cancel + ) + } +} + +private fun AndroidComposeTestRule.setLinkView( + state: LinkState, + onLinkValid: (Link) -> Unit = EnsureNeverCalledWithParam(), +) { + setContent { + LinkView( + state = state, + onLinkValid = onLinkValid, + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt index 7fcbfb62eb..1825373624 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt @@ -12,6 +12,7 @@ import im.vector.app.features.analytics.plan.PinUnpinAction import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator +import io.element.android.features.messages.impl.link.aLinkState import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState @@ -315,6 +316,7 @@ class PinnedMessagesListPresenterTest { timelineProtectionPresenter = { aTimelineProtectionState() }, snackbarDispatcher = SnackbarDispatcher(), actionListPresenter = { anActionListState() }, + linkPresenter = { aLinkState() }, analyticsService = analyticsService, appCoroutineScope = this, ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt index 0906fca360..35ac685303 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt @@ -29,6 +29,7 @@ import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.setSafeContent +import io.element.android.wysiwyg.link.Link import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule @@ -99,8 +100,8 @@ private fun AndroidComposeTestRule.setPinne onBackClick: () -> Unit = EnsureNeverCalled(), onEventClick: (event: TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), - onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(), - onLinkLongClick: (String) -> Unit = EnsureNeverCalledWithParam(), + onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(), + onLinkLongClick: (Link) -> Unit = EnsureNeverCalledWithParam(), ) { setSafeContent { PinnedMessagesListView( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt index fba34f7fab..208c0ab140 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -34,6 +34,7 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.setSafeContent +import io.element.android.wysiwyg.link.Link import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import org.junit.Rule @@ -175,7 +176,7 @@ private fun AndroidComposeTestRule.setTimel state: TimelineState, timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(), onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), - onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(), + onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(), onMessageClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onMessageLongClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onSwipeToReply: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 06e5c80a46..cfab07f3e9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,7 +46,7 @@ coil = "3.1.0" showkase = "1.0.3" appyx = "1.6.0" sqldelight = "2.0.2" -wysiwyg = "2.38.2" +wysiwyg = "2.38.3" telephoto = "0.15.1" # Dependency analysis diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt index c6091c5ffe..48dbcade47 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt @@ -89,3 +89,12 @@ fun String.withoutAccents(): String { return Normalizer.normalize(this, Normalizer.Form.NFD) .replace("\\p{Mn}+".toRegex(), "") } + +private const val RTL_OVERRIDE_CHAR = '\u202E' +private const val LTR_OVERRIDE_CHAR = '\u202D' + +fun String.ensureEndsLeftToRight() = if (containsRtLOverride()) "$this$LTR_OVERRIDE_CHAR" else this + +fun String.containsRtLOverride() = contains(RTL_OVERRIDE_CHAR) + +fun String.filterDirectionOverrides() = filterNot { it == RTL_OVERRIDE_CHAR || it == LTR_OVERRIDE_CHAR } diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt index a31b24adb7..b454412fb7 100644 --- a/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt @@ -8,6 +8,8 @@ package io.element.android.libraries.core.extensions import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class BasicExtensionsTest { @@ -43,4 +45,32 @@ class BasicExtensionsTest { val output = input.ellipsize(5) assertEquals(input, output) } + + @Test + fun `given text with RtL unicode override, when checking contains RtL Override, then returns true`() { + val textWithRtlOverride = "hello\u202Eworld" + val result = textWithRtlOverride.containsRtLOverride() + assertTrue(result) + } + + @Test + fun `given text without RtL unicode override, when checking contains RtL Override, then returns false`() { + val textWithRtlOverride = "hello world" + val result = textWithRtlOverride.containsRtLOverride() + assertFalse(result) + } + + @Test + fun `given text with RtL unicode override, when ensuring ends LtR, then appends a LtR unicode override`() { + val textWithRtlOverride = "123\u202E456" + val result = textWithRtlOverride.ensureEndsLeftToRight() + assertEquals("$textWithRtlOverride\u202D", result) + } + + @Test + fun `given text with unicode direction overrides, when filtering direction overrides, then removes all overrides`() { + val textWithDirectionOverrides = "123\u202E456\u202d789" + val result = textWithDirectionOverrides.filterDirectionOverrides() + assertEquals("123456789", result) + } } diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.link_LinkView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.link_LinkView_Day_0_en.png new file mode 100644 index 0000000000..1b6fb4bab8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.link_LinkView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650 +size 3642 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.link_LinkView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.link_LinkView_Day_1_en.png new file mode 100644 index 0000000000..e815c27364 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.link_LinkView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8b6e164de87ef06710aeef334107086ec751d322cd62dd45a739fa850e9ab1b +size 29300 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.link_LinkView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.link_LinkView_Night_0_en.png new file mode 100644 index 0000000000..d6fd8eeb70 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.link_LinkView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd +size 3659 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.link_LinkView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.link_LinkView_Night_1_en.png new file mode 100644 index 0000000000..17fb62e79c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.link_LinkView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43238bd863880c513672efd8dbf458738e4a4807071f9bd35b37c79e6dac5918 +size 27279