Merge branch 'develop' into feature/fga/timeline-multi-forward

This commit is contained in:
ganfra
2026-10-05 17:54:00 +02:00
335 changed files with 2657 additions and 1194 deletions
@@ -8,7 +8,6 @@
package io.element.android.features.messages.impl
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@@ -650,8 +649,9 @@ class MessagesPresenter(
event.eventId ?: return
room.getPermalinkFor(event.eventId).fold(
onSuccess = { permalink ->
clipboardHelper.copyPlainText(permalink)
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_link_copied_to_clipboard))
clipboardHelper.copyPlainText(permalink) {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_link_copied_to_clipboard))
}
},
onFailure = {
Timber.e(it, "Failed to get permalink for event ${event.eventId}")
@@ -666,16 +666,14 @@ class MessagesPresenter(
is TimelineItemStateContent -> event.content.body
else -> return
}
clipboardHelper.copyPlainText(content)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
clipboardHelper.copyPlainText(content) {
snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_message_copied))
}
}
private fun handleCopyCaption(event: TimelineItem.Event) {
val content = event.content.captionOrNull() ?: return
clipboardHelper.copyPlainText(content)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
clipboardHelper.copyPlainText(content) {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
}
}
@@ -15,7 +15,6 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@@ -26,8 +25,9 @@ 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.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
@@ -53,6 +53,8 @@ class PinnedMessagesListNode(
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val permalinkParser: PermalinkParser,
private val emojiPickerRenderer: EmojiPickerRenderer,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
interface Callback : Plugin {
fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean)
@@ -114,7 +116,6 @@ class PinnedMessagesListNode(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
val context = LocalContext.current
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
val view = LocalView.current
val state = presenter.present()
PinnedMessagesListView(
@@ -132,10 +133,9 @@ class PinnedMessagesListNode(
view.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS
)
context.copyToClipboard(
text = it.url,
toastMessage = toastMessage,
)
clipboardHelper.copyPlainText(it.url) {
toastHelper.show(CommonStrings.common_copied_to_clipboard)
}
},
emojiPickerRenderer = emojiPickerRenderer,
modifier = modifier
@@ -267,6 +267,7 @@ private fun ThreadListItemRow(
UnreadIndicatorAtom(
size = 14.dp,
isVisible = hasUnreadNotifications,
count = 0,
)
}
}
@@ -28,10 +28,10 @@ sealed interface TimelineEvent {
data object HideShieldDialog : TimelineEvent
data object MarkAllAsRead : TimelineEvent
data class EnterSelectionMode(val action: SelectionAction, val eventId: EventId) : TimelineEvent
data class ToggleSelection(val eventId: EventId) : TimelineEvent
data object ExitSelectionMode : TimelineEvent
data class CopyToClipboard(val text: String) : TimelineEvent
/**
* Events coming from a timeline item.
@@ -44,6 +44,8 @@ import io.element.android.features.messages.impl.voicemessages.timeline.Redacted
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@@ -112,6 +114,8 @@ class TimelinePresenter(
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val markAsFullyRead: MarkAsFullyRead,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Presenter<TimelineState> {
private val tag = "TimelinePresenter"
@@ -355,6 +359,11 @@ class TimelinePresenter(
is TimelineEvent.ValidateMedia -> {
timelineProtectionState.eventSink(TimelineProtectionEvent.ValidateContent(event.mediaSources, event.validationState))
}
is TimelineEvent.CopyToClipboard -> {
clipboardHelper.copyPlainText(event.text) {
toastHelper.show(CommonStrings.common_copied_to_clipboard)
}
}
}
}
@@ -64,7 +64,6 @@ import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.vector.ImageVector
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.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
@@ -96,7 +95,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
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.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -171,8 +169,6 @@ fun TimelineView(
state.eventSink(TimelineEvent.FocusOnEvent(eventId))
}
val context = LocalContext.current
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
val view = LocalView.current
fun inReplyToClick(eventId: EventId) {
state.eventSink(TimelineEvent.FocusOnEvent(eventId))
@@ -182,10 +178,7 @@ fun TimelineView(
view.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS
)
context.copyToClipboard(
text = link.url,
toastMessage = toastMessage,
)
state.eventSink(TimelineEvent.CopyToClipboard(link.url))
}
fun prefetchMoreItems() {
@@ -606,6 +599,7 @@ private fun JumpToPositionButton(
.offset { IntOffset(x = 0, y = dotYOffset.roundToPx()) },
color = ElementTheme.colors.iconSuccessPrimary,
border = BorderStroke(2.dp, ElementTheme.colors.bgCanvasDefault),
count = 0,
)
}
}
@@ -16,17 +16,22 @@ import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.ui.strings.CommonStrings
@ContributesNode(RoomScope::class)
@AssistedInject
class EventDebugInfoNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val eventId: EventId?,
@@ -39,6 +44,12 @@ class EventDebugInfoNode(
navigateUp()
}
private fun onCopyText(text: String) {
clipboardHelper.copyPlainText(text) {
toastHelper.show(CommonStrings.common_copied_to_clipboard)
}
}
@Composable
override fun View(modifier: Modifier) = with(inputs) {
EventDebugInfoView(
@@ -46,7 +57,8 @@ class EventDebugInfoNode(
model = timelineItemDebugInfo.model,
originalJson = timelineItemDebugInfo.originalJson,
latestEditedJson = timelineItemDebugInfo.latestEditedJson,
onBackClick = ::onBackClick
onBackClick = ::onBackClick,
onCopyText = ::onCopyText,
)
}
}
@@ -8,8 +8,6 @@
package io.element.android.features.messages.impl.timeline.debug
import android.content.ClipData
import android.content.ClipboardManager
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
@@ -34,11 +32,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.button.BackButton
@@ -63,6 +59,7 @@ fun EventDebugInfoView(
originalJson: String?,
latestEditedJson: String?,
onBackClick: () -> Unit,
onCopyText: (String) -> Unit,
modifier: Modifier = Modifier,
isTest: Boolean = false,
) {
@@ -87,20 +84,30 @@ fun EventDebugInfoView(
item {
Column(Modifier.padding(vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Event ID:")
CopyableText(text = eventId?.value ?: "-", modifier = Modifier.fillMaxWidth())
CopyableText(text = eventId?.value ?: "-", onClick = onCopyText, modifier = Modifier.fillMaxWidth())
}
}
item {
CollapsibleSection(title = "Model:", text = model, initiallyExpanded = sectionsInitiallyExpanded)
CollapsibleSection(title = "Model:", text = model, onCopyText = onCopyText, initiallyExpanded = sectionsInitiallyExpanded)
}
if (originalJson != null) {
item {
CollapsibleSection(title = "Original JSON:", text = prettyJSON(originalJson), initiallyExpanded = sectionsInitiallyExpanded)
CollapsibleSection(
title = "Original JSON:",
text = prettyJSON(originalJson),
onCopyText = onCopyText,
initiallyExpanded = sectionsInitiallyExpanded,
)
}
}
if (latestEditedJson != null) {
item {
CollapsibleSection(title = "Latest edited JSON:", text = prettyJSON(latestEditedJson), initiallyExpanded = sectionsInitiallyExpanded)
CollapsibleSection(
title = "Latest edited JSON:",
text = prettyJSON(latestEditedJson),
onCopyText = onCopyText,
initiallyExpanded = sectionsInitiallyExpanded,
)
}
}
}
@@ -120,6 +127,7 @@ private fun prettyJSON(maybeJSON: String): String {
private fun CollapsibleSection(
title: String,
text: String,
onCopyText: (String) -> Unit,
initiallyExpanded: Boolean = false,
) {
var isExpanded by remember { mutableStateOf(initiallyExpanded) }
@@ -139,7 +147,7 @@ private fun CollapsibleSection(
)
}
AnimatedVisibility(visible = isExpanded, enter = expandVertically(), exit = shrinkVertically()) {
CopyableText(text = text, modifier = Modifier.fillMaxWidth())
CopyableText(text = text, onClick = onCopyText, modifier = Modifier.fillMaxWidth())
}
}
}
@@ -147,16 +155,15 @@ private fun CollapsibleSection(
@Composable
private fun CopyableText(
text: String,
onClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val clipboardManager = remember { requireNotNull(context.getSystemService<ClipboardManager>()) }
Box(
modifier
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(6.dp)
.clickable { clipboardManager.setPrimaryClip(ClipData.newPlainText("JSON", text)) }
.clickable { onClick(text) }
) {
Text(
text = text,
@@ -174,6 +181,7 @@ internal fun EventDebugInfoViewPreview() = ElementPreview {
model = "Rust(\n\tModel()\n)",
originalJson = "{\"name\": \"original\"}",
latestEditedJson = "{\"name\": \"edited\"}",
onBackClick = { }
onBackClick = { },
onCopyText = { },
)
}
@@ -33,6 +33,10 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.androidutils.toast.FakeToastHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlags
@@ -74,6 +78,7 @@ import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem
@@ -1543,6 +1548,38 @@ class TimelinePresenterTest {
}
}
@Test
fun `present - copy to clipboard on old device shows a toast`() = runTest {
val clipboardHelper = FakeClipboardHelper(isOldDevice = true)
val toastHelper = FakeToastHelper()
val presenter = createTimelinePresenter(
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
presenter.test {
awaitFirstItem().eventSink(TimelineEvent.CopyToClipboard("https://element.io"))
assertThat(clipboardHelper.clipboardContents).isEqualTo("https://element.io")
assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_copied_to_clipboard)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - copy to clipboard on new device does not show a toast`() = runTest {
val clipboardHelper = FakeClipboardHelper(isOldDevice = false)
val toastHelper = FakeToastHelper()
val presenter = createTimelinePresenter(
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
presenter.test {
awaitFirstItem().eventSink(TimelineEvent.CopyToClipboard("https://element.io"))
assertThat(clipboardHelper.clipboardContents).isEqualTo("https://element.io")
assertThat(toastHelper.shownToasts).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - show shield hide shield`() = runTest {
val presenter = createTimelinePresenter()
@@ -2045,6 +2082,8 @@ class TimelinePresenterTest {
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> },
timelineProtectionPresenter: Presenter<TimelineProtectionState> = { aTimelineProtectionState() },
resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState> = { aResolveVerifiedUserSendFailureState() },
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
toastHelper: ToastHelper = FakeToastHelper(),
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
@@ -2072,6 +2111,8 @@ class TimelinePresenterTest {
liveLocationShareManager = liveLocationShareManager,
markAsFullyRead = markAsFullyRead,
timelineProtectionPresenter = timelineProtectionPresenter,
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
}
}