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 <android@element.io>
This commit is contained in:
@@ -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<TimelineState>,
|
||||
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
|
||||
private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
|
||||
private val linkPresenter: Presenter<LinkState>,
|
||||
@Assisted private val actionListPresenter: Presenter<ActionListState>,
|
||||
private val customReactionPresenter: Presenter<CustomReactionState>,
|
||||
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TimelineProtectionState>
|
||||
|
||||
@Binds
|
||||
fun bindLinkPresenter(presenter: LinkPresenter): Presenter<LinkState>
|
||||
|
||||
@Binds
|
||||
fun bindVoiceMessageComposerPresenter(presenter: VoiceMessageComposerPresenter): Presenter<VoiceMessageComposerState>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<LinkState> {
|
||||
@Composable
|
||||
override fun present(): LinkState {
|
||||
val linkClick: MutableState<AsyncAction<Link>> = 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Link>,
|
||||
val eventSink: (LinkEvents) -> Unit,
|
||||
)
|
||||
@@ -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<LinkState> {
|
||||
override val values: Sequence<LinkState>
|
||||
get() = sequenceOf(
|
||||
aLinkState(),
|
||||
aLinkState(
|
||||
linkClick = ConfirmingLinkClick(
|
||||
Link(
|
||||
url = "https://evil.io",
|
||||
text = "https://element.io"
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aLinkState(
|
||||
linkClick: AsyncAction<Link> = AsyncAction.Uninitialized,
|
||||
eventSink: (LinkEvents) -> Unit = {},
|
||||
) = LinkState(
|
||||
linkClick = linkClick,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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<TimelineProtectionState>,
|
||||
private val linkPresenter: Presenter<LinkState>,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
@Assisted private val actionListPresenter: Presenter<ActionListState>,
|
||||
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<ImmutableList<TimelineItem>>,
|
||||
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,
|
||||
|
||||
@@ -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<TimelineItem>,
|
||||
val actionListState: ActionListState,
|
||||
val linkState: LinkState,
|
||||
val eventSink: (PinnedMessagesListEvents) -> Unit,
|
||||
) : PinnedMessagesListState {
|
||||
val loadedPinnedMessagesCount = timelineItems.count { timelineItem -> timelineItem is TimelineItem.Event }
|
||||
|
||||
@@ -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<TimelineItem> = 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {},
|
||||
) {
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<Link, Boolean> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -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<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `clicking on cancel emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<LinkEvents>()
|
||||
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<LinkEvents>()
|
||||
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<LinkEvents>()
|
||||
ensureCalledOnceWithParam(aLink) { callback ->
|
||||
rule.setLinkView(
|
||||
aLinkState(
|
||||
linkClick = AsyncAction.Success(aLink),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onLinkValid = callback,
|
||||
)
|
||||
}
|
||||
eventsRecorder.assertSingle(
|
||||
LinkEvents.Cancel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setLinkView(
|
||||
state: LinkState,
|
||||
onLinkValid: (Link) -> Unit = EnsureNeverCalledWithParam(),
|
||||
) {
|
||||
setContent {
|
||||
LinkView(
|
||||
state = state,
|
||||
onLinkValid = onLinkValid,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.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(
|
||||
|
||||
@@ -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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user