feat: Long press link to copy URL to clipboard

This commit is contained in:
夜坂雅 2025-03-07 22:56:56 +08:00
parent 168262176b
commit 6c4cbcbfb8
No known key found for this signature in database
GPG key ID: A24CE3ACA80FD3E4
13 changed files with 74 additions and 0 deletions

View file

@ -8,10 +8,12 @@
package io.element.android.features.messages.impl.pinned.list
import android.content.Context
import android.view.HapticFeedbackConstants
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@ -23,6 +25,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
@ -30,6 +33,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.ui.strings.CommonStrings
@ContributesNode(RoomScope::class)
class PinnedMessagesListNode @AssistedInject constructor(
@ -98,6 +102,7 @@ class PinnedMessagesListNode @AssistedInject constructor(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
val context = LocalContext.current
val view = LocalView.current
val state = presenter.present()
PinnedMessagesListView(
state = state,
@ -105,6 +110,15 @@ class PinnedMessagesListNode @AssistedInject constructor(
onEventClick = ::onEventClick,
onUserDataClick = ::onUserDataClick,
onLinkClick = { url -> onLinkClick(context, url) },
onLinkLongClick = {
view.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS
)
context.copyToClipboard(
it,
context.getString(CommonStrings.common_copied_to_clipboard)
)
},
modifier = modifier
)
}

View file

@ -58,6 +58,7 @@ fun PinnedMessagesListView(
onEventClick: (event: TimelineItem.Event) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
@ -78,6 +79,7 @@ fun PinnedMessagesListView(
onEventClick = onEventClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onErrorDismiss = onBackClick,
modifier = Modifier
.padding(padding)
@ -112,6 +114,7 @@ private fun PinnedMessagesListContent(
onEventClick: (event: TimelineItem.Event) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
onErrorDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -130,6 +133,7 @@ private fun PinnedMessagesListContent(
onEventClick = onEventClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
)
PinnedMessagesListState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@ -166,6 +170,7 @@ private fun PinnedMessagesListLoaded(
onEventClick: (event: TimelineItem.Event) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
fun onActionSelected(timelineItemAction: TimelineItemAction, event: TimelineItem.Event) {
@ -216,6 +221,7 @@ private fun PinnedMessagesListLoaded(
focusedEventId = null,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentClick = onEventClick,
onLongClick = ::onMessageLongClick,
inReplyToClick = {},
@ -233,6 +239,7 @@ private fun PinnedMessagesListLoaded(
onContentClick = { onEventClick(event) },
onLongClick = { onMessageLongClick(event) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange
)
@ -248,6 +255,7 @@ private fun TimelineItemEventContentViewWrapper(
timelineProtectionState: TimelineProtectionState,
onContentClick: () -> Unit,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
onLongClick: (() -> Unit)?,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
@ -264,6 +272,7 @@ private fun TimelineItemEventContentViewWrapper(
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = { },
modifier = modifier,
onContentClick = onContentClick,
@ -283,5 +292,6 @@ internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListS
onEventClick = { },
onUserDataClick = {},
onLinkClick = {},
onLinkLongClick = {},
)
}

View file

@ -7,6 +7,7 @@
package io.element.android.features.messages.impl.timeline
import android.view.HapticFeedbackConstants
import android.view.accessibility.AccessibilityManager
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
@ -41,6 +42,7 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -59,6 +61,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -106,6 +109,7 @@ fun TimelineView(
}
val context = LocalContext.current
val view = LocalView.current
// Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version
val useReverseLayout = remember {
val accessibilityManager = context.getSystemService(AccessibilityManager::class.java)
@ -116,6 +120,16 @@ fun TimelineView(
state.eventSink(TimelineEvents.FocusOnEvent(eventId))
}
fun onLinkLongClick(link: String) {
view.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS
)
context.copyToClipboard(
link,
context.getString(CommonStrings.common_copied_to_clipboard)
)
}
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) {
@ -141,6 +155,7 @@ fun TimelineView(
focusedEventId = state.focusedEventId,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = ::onLinkLongClick,
onContentClick = onContentClick,
onLongClick = onMessageLongClick,
inReplyToClick = ::inReplyToClick,

View file

@ -33,6 +33,7 @@ internal fun ATimelineItemEventRow(
onEventClick = {},
onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onUserDataClick = {},
inReplyToClick = {},
onReactionClick = { _, _ -> },

View file

@ -118,6 +118,7 @@ fun TimelineItemEventRow(
onEventClick: () -> Unit,
onLongClick: () -> Unit,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit,
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
@ -138,6 +139,7 @@ fun TimelineItemEventRow(
onLongClick = onLongClick,
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = eventSink,
modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange

View file

@ -46,6 +46,7 @@ fun TimelineItemGroupedEventsRow(
inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
@ -59,6 +60,7 @@ fun TimelineItemGroupedEventsRow(
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = eventSink,
modifier = contentModifier,
onContentClick = null,
@ -87,6 +89,7 @@ fun TimelineItemGroupedEventsRow(
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
@ -112,6 +115,7 @@ private fun TimelineItemGroupedEventsRowContent(
inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
@ -125,6 +129,7 @@ private fun TimelineItemGroupedEventsRowContent(
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = eventSink,
modifier = contentModifier,
onContentClick = null,
@ -156,6 +161,7 @@ private fun TimelineItemGroupedEventsRowContent(
focusedEventId = focusedEventId,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
@ -199,6 +205,7 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
isLastOutgoingMessage = false,
onClick = {},
onLongClick = {},
onLinkLongClick = {},
inReplyToClick = {},
onUserDataClick = {},
onLinkClick = {},
@ -224,6 +231,7 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
isLastOutgoingMessage = false,
onClick = {},
onLongClick = {},
onLinkLongClick = {},
inReplyToClick = {},
onUserDataClick = {},
onLinkClick = {},

View file

@ -43,6 +43,7 @@ internal fun TimelineItemRow(
focusedEventId: EventId?,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
@ -63,6 +64,7 @@ internal fun TimelineItemRow(
onContentClick = { onContentClick(event) },
onLongClick = { onLongClick(event) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = eventSink,
modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange
@ -122,6 +124,7 @@ internal fun TimelineItemRow(
onEventClick = { onContentClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onUserDataClick = onUserDataClick,
inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,
@ -150,6 +153,7 @@ internal fun TimelineItemRow(
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,

View file

@ -71,6 +71,7 @@ fun TimelineItemStateEventRow(
TimelineItemEventContentView(
content = event.content,
onLinkClick = {},
onLinkLongClick = {},
hideMediaContent = false,
onShowContentClick = {},
eventSink = eventSink,

View file

@ -40,6 +40,7 @@ fun TimelineItemEventContentView(
onLongClick: (() -> Unit)?,
onShowContentClick: () -> Unit,
onLinkClick: (url: String) -> Unit,
onLinkLongClick: (String) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
@ -60,6 +61,7 @@ fun TimelineItemEventContentView(
content = content,
modifier = modifier,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = onContentLayoutChange
)
is TimelineItemUnknownContent -> TimelineItemUnknownView(
@ -78,6 +80,7 @@ fun TimelineItemEventContentView(
onLongClick = onLongClick,
onShowContentClick = onShowContentClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
)
@ -96,6 +99,7 @@ fun TimelineItemEventContentView(
onLongClick = onLongClick,
onShowContentClick = onShowContentClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier
)

View file

@ -65,6 +65,7 @@ fun TimelineItemImageView(
onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
onShowContentClick: () -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
@ -120,6 +121,7 @@ fun TimelineItemImageView(
text = caption,
style = ElementRichTextEditorStyle.textStyle(),
onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick,
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
)
@ -138,6 +140,7 @@ internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageCon
onContentClick = {},
onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
)
}
@ -152,6 +155,7 @@ internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview {
onContentClick = {},
onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
)
}

View file

@ -46,6 +46,7 @@ import io.element.android.wysiwyg.compose.EditorStyledText
fun TimelineItemTextView(
content: TimelineItemTextBasedContent,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
modifier: Modifier = Modifier,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
) {
@ -64,6 +65,7 @@ fun TimelineItemTextView(
EditorStyledText(
text = body,
onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick,
style = ElementRichTextEditorStyle.textStyle(),
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
releaseOnDetach = false,
@ -115,6 +117,7 @@ internal fun TimelineItemTextViewPreview(
TimelineItemTextView(
content = content,
onLinkClick = {},
onLinkLongClick = {},
)
}
@ -127,6 +130,7 @@ internal fun TimelineItemTextViewWithLinkifiedUrlPreview() = ElementPreview {
TimelineItemTextView(
content = content,
onLinkClick = {},
onLinkLongClick = {},
)
}
@ -139,5 +143,6 @@ internal fun TimelineItemTextViewWithLinkifiedUrlAndNestedParenthesisPreview() =
TimelineItemTextView(
content = content,
onLinkClick = {},
onLinkLongClick = {},
)
}

View file

@ -74,6 +74,7 @@ fun TimelineItemVideoView(
onLongClick: (() -> Unit)?,
onShowContentClick: () -> Unit,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
@ -147,6 +148,7 @@ fun TimelineItemVideoView(
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
text = caption,
onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick,
style = ElementRichTextEditorStyle.textStyle(),
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
@ -166,6 +168,7 @@ internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoCon
onContentClick = {},
onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
)
}
@ -180,6 +183,7 @@ internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview {
onContentClick = {},
onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
)
}

View file

@ -100,6 +100,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinne
onEventClick: (event: TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(),
onLinkLongClick: (String) -> Unit = EnsureNeverCalledWithParam(),
) {
setSafeContent {
PinnedMessagesListView(
@ -108,6 +109,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinne
onEventClick = onEventClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
)
}
}