Merge branch 'develop' into sync-localazy

This commit is contained in:
ganfra
2026-09-18 10:39:43 +02:00
committed by GitHub
214 changed files with 3639 additions and 707 deletions
@@ -39,5 +39,6 @@ fun aVoiceMessagePreviewState() = VoiceMessageState.Preview(
showCursor = false,
playbackProgress = 0f,
time = 10.seconds,
duration = 10.seconds,
waveform = WaveFormSamples.realisticWaveForm,
)
+1
View File
@@ -59,6 +59,7 @@ dependencies {
implementation(projects.libraries.voicerecorder.api)
implementation(projects.libraries.mediaplayer.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.testtags)
implementation(projects.libraries.wellknown.api)
@@ -18,6 +18,8 @@ sealed interface MessagesEvent {
data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvent
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvent
data class OnUserClicked(val user: MatrixUser) : MessagesEvent
data class ConfirmRedact(val reason: String?) : MessagesEvent
data object CancelRedact : MessagesEvent
data object StopLiveLocationShare : MessagesEvent
data object ShowLiveLocationShare : MessagesEvent
data object MarkAsFullyReadAndExit : MessagesEvent
@@ -51,6 +51,7 @@ import io.element.android.libraries.androidutils.system.toast
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.ApplicationContext
@@ -103,6 +104,7 @@ class MessagesNode(
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
private val eventContentValidationCache: EventContentValidationCache,
private val emojiPickerRenderer: EmojiPickerRenderer,
private val dispatchers: CoroutineDispatchers,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
data class Inputs(
val focusedEventId: EventId?,
@@ -111,7 +113,12 @@ class MessagesNode(
private val inputs = inputs<Inputs>()
private val callback: Callback = callback()
private val timelineController = TimelineController(room, room.liveTimeline)
private val timelineController = TimelineController(
room = room,
liveTimeline = room.liveTimeline,
roomCoroutineScope = room.roomCoroutineScope,
dispatchers = dispatchers,
)
private val presenter = presenterFactory.create(
navigator = this,
composerPresenter = messageComposerPresenterFactory.create(timelineController, this, threadRoot = null),
@@ -58,6 +58,7 @@ import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@@ -84,6 +85,7 @@ import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibilit
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.dmUserStatus
import io.element.android.libraries.matrix.ui.model.getAvatarData
@@ -241,6 +243,8 @@ class MessagesPresenter(
onPauseOrDispose {}
}
val redactEventAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
fun handleEvent(event: MessagesEvent) {
when (event) {
is MessagesEvent.HandleAction -> {
@@ -251,8 +255,21 @@ class MessagesPresenter(
enableTextFormatting = composerState.showTextFormatting,
timelineState = timelineState,
timelineProtectionState = timelineProtectionState,
redactEventAction = redactEventAction,
)
}
is MessagesEvent.ConfirmRedact -> {
val confirming = redactEventAction.value as? MessagesState.ConfirmingRedaction
redactEventAction.value = AsyncAction.Uninitialized
if (confirming != null) {
localCoroutineScope.launch {
redact(confirming.eventId.toEventOrTransactionId(), event.reason?.takeIf { it.isNotBlank() })
}
}
}
MessagesEvent.CancelRedact -> {
redactEventAction.value = AsyncAction.Uninitialized
}
is MessagesEvent.ToggleReaction -> {
localCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId)
}
@@ -331,6 +348,7 @@ class MessagesPresenter(
hasUnreadThreads = false,
),
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom && timelineState.timelineMode !is Timeline.Mode.Thread,
redactEventAction = redactEventAction.value,
eventSink = ::handleEvent,
)
}
@@ -369,12 +387,13 @@ class MessagesPresenter(
timelineProtectionState: TimelineProtectionState,
enableTextFormatting: Boolean,
timelineState: TimelineState,
redactEventAction: MutableState<AsyncAction<Unit>>,
) = launch {
when (action) {
TimelineItemAction.CopyText -> handleCopyContents(targetEvent)
TimelineItemAction.CopyCaption -> handleCopyCaption(targetEvent)
TimelineItemAction.CopyLink -> handleCopyLink(targetEvent)
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Redact -> handleActionRedact(targetEvent, redactEventAction)
TimelineItemAction.Edit,
TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
@@ -496,9 +515,19 @@ class MessagesPresenter(
)
}
private suspend fun handleActionRedact(event: TimelineItem.Event) {
private suspend fun handleActionRedact(event: TimelineItem.Event, redactEventAction: MutableState<AsyncAction<Unit>>) {
val eventId = event.eventId
if (eventId == null) {
// The message was never sent, so there is nobody to give a reason to.
redact(event.eventOrTransactionId, reason = null)
} else {
redactEventAction.value = MessagesState.ConfirmingRedaction(eventId)
}
}
private suspend fun redact(eventOrTransactionId: EventOrTransactionId, reason: String?) {
timelineController.invokeOnCurrentTimeline {
redactEvent(eventOrTransactionId = event.eventOrTransactionId, reason = null)
redactEvent(eventOrTransactionId = eventOrTransactionId, reason = reason)
.onFailure { Timber.e(it) }
}
}
@@ -21,9 +21,11 @@ import io.element.android.features.messages.impl.timeline.components.receipt.bot
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
@@ -61,6 +63,7 @@ data class MessagesState(
val successorRoom: SuccessorRoom?,
val threads: Threads,
val showLiveLocationShareBanner: Boolean,
val redactEventAction: AsyncAction<Unit>,
val eventSink: (MessagesEvent) -> Unit
) {
val isTombstoned = successorRoom != null
@@ -69,6 +72,11 @@ data class MessagesState(
val hasThreads: Boolean,
val hasUnreadThreads: Boolean,
)
/** The user asked to remove [eventId] and has to confirm, optionally giving a reason. */
data class ConfirmingRedaction(
val eventId: EventId,
) : AsyncAction.Confirming
}
/** Type of "shared history" icon to show in the top bar. */
@@ -41,10 +41,12 @@ import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent
import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
@@ -92,6 +94,7 @@ open class MessagesStatePreviewParam : PreviewParameterProvider<MessagesState> {
composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()),
identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange()))
),
aMessagesState(redactEventAction = MessagesState.ConfirmingRedaction(EventId("\$anEventId"))),
)
}
@@ -131,6 +134,7 @@ fun aMessagesState(
isCurrentlySharingLiveLocationInRoom: Boolean = false,
dmUserStatus: DisplayedStatus? = null,
eventSink: (MessagesEvent) -> Unit = {},
redactEventAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
) = MessagesState(
roomId = RoomId("!id:domain"),
roomName = roomName,
@@ -161,6 +165,7 @@ fun aMessagesState(
threads = threads,
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom,
dmUserStatus = dmUserStatus,
redactEventAction = redactEventAction,
eventSink = eventSink,
)
@@ -104,6 +104,7 @@ import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertM
import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayout
import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayoutState
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.TextFieldDialog
import io.element.android.libraries.designsystem.components.rememberExpandableBottomSheetLayoutState
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -455,6 +456,20 @@ fun MessagesView(
state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog)
},
)
if (state.redactEventAction is MessagesState.ConfirmingRedaction) {
TextFieldDialog(
title = stringResource(R.string.screen_room_confirm_removal_title),
content = stringResource(R.string.screen_room_confirm_removal_message),
placeholder = stringResource(R.string.screen_room_confirm_removal_reason_placeholder_android),
supportingText = stringResource(R.string.screen_room_confirm_removal_reason_supporting_text),
value = null,
submitText = stringResource(CommonStrings.action_remove),
destructiveSubmit = true,
onSubmit = { reason -> state.eventSink(MessagesEvent.ConfirmRedact(reason)) },
onDismissRequest = { state.eventSink(MessagesEvent.CancelRedact) },
)
}
}
@Composable
@@ -36,7 +36,6 @@ import io.element.android.features.messages.impl.timeline.components.TimelineIte
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -58,6 +57,7 @@ import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
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
@@ -54,6 +54,7 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
@@ -95,6 +96,7 @@ class ThreadedMessagesNode(
private val appNavigationStateService: AppNavigationStateService,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
private val emojiPickerRenderer: EmojiPickerRenderer,
private val dispatchers: CoroutineDispatchers,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
data class Inputs(
val threadRootEventId: ThreadId,
@@ -113,7 +115,12 @@ class ThreadedMessagesNode(
*/
private suspend fun createPresenter(): Presenter<MessagesState> {
val threadedTimeline = room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = inputs.threadRootEventId)).getOrThrow()
val timelineController = TimelineController(room, threadedTimeline)
val timelineController = TimelineController(
room,
liveTimeline = threadedTimeline,
roomCoroutineScope = room.roomCoroutineScope,
dispatchers = dispatchers,
)
this.timelineController = timelineController
return presenterFactory.create(
navigator = this,
@@ -12,7 +12,10 @@ import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metro.binding
import io.element.android.features.messages.impl.timeline.di.LiveTimeline
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.RoomCoroutineScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
@@ -23,7 +26,6 @@ import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -47,8 +49,10 @@ import java.util.Optional
class TimelineController(
private val room: JoinedRoom,
@LiveTimeline private val liveTimeline: Timeline,
@RoomCoroutineScope private val roomCoroutineScope: CoroutineScope,
dispatchers: CoroutineDispatchers,
) : Closeable, TimelineProvider {
private val coroutineScope = CoroutineScope(SupervisorJob())
private val coroutineScope = roomCoroutineScope.childScope(dispatchers.computation, "TimelineController")
private val liveTimelineFlow = flowOf(liveTimeline)
private val detachedTimelineFlow = MutableStateFlow<Optional<Timeline>>(Optional.empty())
@@ -72,8 +72,6 @@ import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -139,6 +137,8 @@ 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.testtags.testTag
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
@@ -23,7 +23,6 @@ import io.element.android.features.messages.impl.timeline.aRedactedMessagesGroup
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.groups.isRedactedMessagesGroup
@@ -35,6 +34,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
import io.element.android.wysiwyg.link.Link
@Composable
@@ -29,7 +29,6 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
@@ -46,6 +45,7 @@ import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.link.Link
@@ -22,12 +22,12 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
/**
* package-private, you should only use TimelineItemFileView and TimelineItemAudioView.
@@ -12,11 +12,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentPreviewParam
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
@Composable
fun TimelineItemAudioView(
@@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContentPreviewParam
import io.element.android.libraries.designsystem.icons.CompoundDrawables
@@ -20,6 +19,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -24,8 +24,6 @@ import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.rememberPresenter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
@@ -60,6 +58,8 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundCo
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@@ -13,11 +13,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentPreviewParam
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
import io.element.android.libraries.ui.strings.CommonStrings
/**
@@ -22,12 +22,12 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
@Composable
fun TimelineItemInformativeView(
@@ -11,11 +11,11 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -23,8 +23,6 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.AN_EMOJI_ONLY_TEXT
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentPreviewParam
@@ -36,6 +34,8 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@@ -11,11 +11,11 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -40,7 +40,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentPreviewParam
import io.element.android.libraries.designsystem.atomic.atoms.PlaybackSpeedButton
@@ -52,6 +51,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
@@ -142,6 +142,9 @@ fun TimelineItemVoiceView(
modifier = Modifier
.weight(1f)
.height(34.dp),
isPlaying = state.isPlaying,
durationMs = state.durationMs,
playbackSpeed = state.playbackSpeed,
seekEnabled = !isTalkbackActive(),
onSeek = { state.eventSink(VoiceMessageEvent.Seek(it)) },
)
@@ -1,209 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.timeline.components.layout
import android.text.Layout
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.style.ResolvedTextDirection
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
import io.element.android.wysiwyg.compose.EditorStyledText
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
/**
* A layout with 2 children: the [content] and the [overlay].
*
* It will try to place the [overlay] on top of the [content] if possible, avoiding the area of it that is non-overlapping.
* If the [overlay] can't be placed on top of the [content], it will be placed to the right of it, if it fits, otherwise, to its bottom in a new row.
*
* @param overlay The 'overlay' component of the layout, which will be positioned relative to the [content].
* @param modifier The modifier for the layout.
* @param spacing The spacing between the [content] and the [overlay]. Defaults to `0.dp`.
* @param overlayOffset The offset of the [overlay] from the bottom right corner of the [content].
* @param shrinkContent Whether the content should be shrunk to fit the available width or not. Defaults to `false`.
* @param content The 'content' component of the layout.
*/
@Suppress("ContentSlotReused") // Since we added an exception for `LocalUiTestMode`, detekt thinks the layout can change in runtime: it won't
@Composable
fun ContentAvoidingLayout(
overlay: @Composable () -> Unit,
modifier: Modifier = Modifier,
spacing: Dp = 0.dp,
overlayOffset: DpOffset = DpOffset.Zero,
shrinkContent: Boolean = false,
content: @Composable ContentAvoidingLayoutScope.() -> Unit,
) {
val scope = remember { ContentAvoidingLayoutScopeInstance() }
// Custom layouts don't seem to work well with Compose UI tests (they crash), so we use a Column instead when running in test mode.
if (LocalUiTestMode.current) {
Column {
scope.content()
overlay()
}
return
}
Layout(
modifier = modifier,
content = {
scope.content()
overlay()
}
) { measurables, constraints ->
// Measure the `overlay` view first, in case we need to shrink the `content`
val overlayPlaceable = measurables.last().measure(Constraints(minWidth = 0, maxWidth = constraints.maxWidth))
val contentConstraints = if (shrinkContent) {
Constraints(minWidth = 0, maxWidth = constraints.maxWidth - overlayPlaceable.width)
} else {
Constraints(minWidth = 0, maxWidth = constraints.maxWidth)
}
val contentPlaceable = measurables.first().measure(contentConstraints)
var layoutWidth = contentPlaceable.width
var layoutHeight = contentPlaceable.height
val data = scope.data.value
// Free space = width of the whole component - width of its non overlapping contents
val freeSpace = max(contentPlaceable.width - data.nonOverlappingContentWidth, 0)
when {
// When the content + the overlay don't fit in the available max width, we need to move the overlay to a new row
!shrinkContent && data.nonOverlappingContentWidth + overlayPlaceable.width > constraints.maxWidth -> {
layoutHeight += overlayPlaceable.height + overlayOffset.y.roundToPx()
}
// If the content is smaller than the available max width, we can move the overlay to the right of the content
contentPlaceable.width < constraints.maxWidth -> {
// If both the content and the overlay plus the padding can fit inside the current layoutWidth, there is no need to increase it
if (freeSpace < overlayPlaceable.width + spacing.roundToPx()) {
// Otherwise, we need to increase it by the width of the overlay + some padding adjustments
val calculatedWidth = max(data.nonOverlappingContentWidth + overlayPlaceable.width + spacing.roundToPx(), contentPlaceable.width)
layoutWidth = min(calculatedWidth, constraints.maxWidth)
}
}
else -> Unit
}
layoutWidth = max(layoutWidth, constraints.minWidth)
layoutHeight = max(layoutHeight, constraints.minHeight)
layout(layoutWidth, layoutHeight) {
contentPlaceable.placeRelative(0, 0)
overlayPlaceable.placeRelative(layoutWidth - overlayPlaceable.width, layoutHeight - overlayPlaceable.height + overlayOffset.y.roundToPx())
}
}
}
/**
* Data class to hold the content layout data.
* This is used to pass the data from the content to the [ContentAvoidingLayout].
*
* @param contentWidth The full width of the content in pixels.
* @param contentHeight The full height of the content in pixels.
* @param nonOverlappingContentWidth The width of the part of the content that can't overlap with the timestamp.
* @param nonOverlappingContentHeight The height of the part of the content that can't overlap with the timestamp.
*/
data class ContentAvoidingLayoutData(
val contentWidth: Int = 0,
val contentHeight: Int = 0,
val nonOverlappingContentWidth: Int = contentWidth,
val nonOverlappingContentHeight: Int = contentHeight,
)
/**
* A scope for the [ContentAvoidingLayout].
*/
interface ContentAvoidingLayoutScope {
/**
* It should be called when the content layout changes, so it can update the [ContentAvoidingLayoutData] and measure and layout the content properly.
*/
fun onContentLayoutChange(data: ContentAvoidingLayoutData)
}
private class ContentAvoidingLayoutScopeInstance(
val data: MutableState<ContentAvoidingLayoutData> = mutableStateOf(ContentAvoidingLayoutData()),
) : ContentAvoidingLayoutScope {
override fun onContentLayoutChange(data: ContentAvoidingLayoutData) {
this.data.value = data
}
}
object ContentAvoidingLayout {
/**
* Measures the last line of a [TextLayoutResult] and calls [onContentLayoutChange] with the [ContentAvoidingLayoutData].
*
* This is supposed to be used in the `onTextLayout` parameter of a Text based component.
*/
@Composable
internal fun measureLastTextLine(
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
extraWidth: Dp = 0.dp,
): ((TextLayoutResult) -> Unit) {
val extraWidthPx = extraWidth.roundToPx()
return { textLayout: TextLayoutResult ->
// We need to add the external extra width so it's not taken into account as 'free space'
val textDirection = tryOrNull { textLayout.getParagraphDirection(0) }
val lastLineWidth = when (textDirection) {
ResolvedTextDirection.Rtl -> textLayout.getLineLeft(textLayout.lineCount - 1).roundToInt()
else -> textLayout.getLineRight(textLayout.lineCount - 1).roundToInt()
}
val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1).roundToInt()
onContentLayoutChange(
ContentAvoidingLayoutData(
contentWidth = textLayout.size.width + extraWidthPx,
contentHeight = textLayout.size.height,
nonOverlappingContentWidth = lastLineWidth + extraWidthPx,
nonOverlappingContentHeight = lastLineHeight,
)
)
}
}
/**
* Measures the last line of a [Layout] and calls [onContentLayoutChange] with the [ContentAvoidingLayoutData].
*
* This is supposed to be used in the `onTextLayout` parameter of an [EditorStyledText] component.
*/
@Composable
internal fun measureLegacyLastTextLine(
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
extraWidth: Dp = 0.dp,
): ((Layout) -> Unit) {
val extraWidthPx = extraWidth.roundToPx()
return { textLayout: Layout ->
// We need to add the external extra width so it's not taken into account as 'free space'
val lastLineWidth = textLayout.getLineWidth(textLayout.lineCount - 1).roundToInt()
val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1)
onContentLayoutChange(
ContentAvoidingLayoutData(
contentWidth = textLayout.width + extraWidthPx,
contentHeight = textLayout.height,
nonOverlappingContentWidth = lastLineWidth + extraWidthPx,
nonOverlappingContentHeight = lastLineHeight,
)
)
}
}
}
@@ -245,12 +245,16 @@ class DefaultVoiceMessageComposerPresenter(
val playerTime by remember(playerState, recorderState) { derivedStateOf { displayTime(playerState, recorderState) } }
val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } }
val recorderDuration = (recorderState as VoiceRecorderState.Finished).duration
val duration = playerState.durationMs?.milliseconds ?: recorderDuration
return VoiceMessageState.Preview(
isSending = isSending,
isPlaying = playerState.isPlaying,
showCursor = showCursor,
playbackProgress = playerState.progress,
time = playerTime,
duration = duration,
waveform = waveform,
)
}
@@ -63,6 +63,7 @@ class VoiceMessageComposerPlayer(
playState = it.playState,
currentPosition = it.currentPosition,
progress = calcProgress(it),
durationMs = if (it.playState != PlayState.Stopped) it.duration else null,
)
}.distinctUntilChanged()
@@ -183,17 +184,20 @@ class VoiceMessageComposerPlayer(
* @property playState Whether this player is currently playing. See [PlayState].
* @property currentPosition The elapsed time of this player in milliseconds.
* @property progress The progress of this player between 0 and 1.
* @property durationMs The player-reported duration in milliseconds, when known and the player is not stopped.
*/
data class State(
val playState: PlayState,
val currentPosition: Long,
val progress: Float,
val durationMs: Long?,
) {
companion object {
val Initial = State(
playState = PlayState.Stopped,
currentPosition = 0L,
progress = 0f,
durationMs = null,
)
}
@@ -48,6 +48,10 @@
<string name="screen_room_attachment_source_location">"Udostępnij lokalizację"</string>
<string name="screen_room_attachment_source_poll">"Ankieta"</string>
<string name="screen_room_attachment_text_formatting">"Formatowanie tekstu"</string>
<string name="screen_room_confirm_removal_message">"Czy na pewno chcesz usunąć wiadomość?"</string>
<string name="screen_room_confirm_removal_reason_label">"Powód (opcjonalne)"</string>
<string name="screen_room_confirm_removal_reason_placeholder">"Powód…"</string>
<string name="screen_room_confirm_removal_title">"Potwierdź usunięcie"</string>
<string name="screen_room_encrypted_history_banner">"Historia wiadomości jest obecnie niedostępna."</string>
<string name="screen_room_encrypted_history_banner_unverified">"Historia wiadomości jest niedostępna w tym pokoju. Zweryfikuj to urządzenie, aby zobaczyć historię wiadomości."</string>
<string name="screen_room_invite_again_alert_message">"Czy chcesz zaprosić ich z powrotem?"</string>
@@ -48,6 +48,10 @@
<string name="screen_room_attachment_source_location">"Поделиться местоположением"</string>
<string name="screen_room_attachment_source_poll">"Опрос"</string>
<string name="screen_room_attachment_text_formatting">"Форматирование текста"</string>
<string name="screen_room_confirm_removal_message">"Вы уверены, что хотите удалить это сообщение?"</string>
<string name="screen_room_confirm_removal_reason_label">"Причина (необязательно)"</string>
<string name="screen_room_confirm_removal_reason_placeholder">"Введите…"</string>
<string name="screen_room_confirm_removal_title">"Подтвердите удаление"</string>
<string name="screen_room_encrypted_history_banner">"В настоящее время история сообщений недоступна в этой комнате."</string>
<string name="screen_room_encrypted_history_banner_unverified">"История сообщений в этой комнате недоступна. Проверьте это устройство, чтобы увидеть историю сообщений."</string>
<string name="screen_room_invite_again_alert_message">"Хотите пригласить их снова?"</string>
@@ -46,6 +46,10 @@
<string name="screen_room_attachment_source_location">"共享位置"</string>
<string name="screen_room_attachment_source_poll">"投票"</string>
<string name="screen_room_attachment_text_formatting">"文本格式化"</string>
<string name="screen_room_confirm_removal_message">"确定要移除此消息?"</string>
<string name="screen_room_confirm_removal_reason_label">"理由(可选)"</string>
<string name="screen_room_confirm_removal_reason_placeholder">"输入…"</string>
<string name="screen_room_confirm_removal_title">"确认移除"</string>
<string name="screen_room_encrypted_history_banner">"消息历史记录当前不可用。"</string>
<string name="screen_room_encrypted_history_banner_unverified">"消息历史在此房间不可用。请验证此设备以查看。"</string>
<string name="screen_room_invite_again_alert_message">"你想邀请他们回来吗?"</string>
@@ -47,6 +47,12 @@
<string name="screen_room_attachment_source_location">"Share location"</string>
<string name="screen_room_attachment_source_poll">"Poll"</string>
<string name="screen_room_attachment_text_formatting">"Text Formatting"</string>
<string name="screen_room_confirm_removal_message">"Are you sure you wish to remove this message?"</string>
<string name="screen_room_confirm_removal_reason_label">"Reason (optional)"</string>
<string name="screen_room_confirm_removal_reason_placeholder">"Enter…"</string>
<string name="screen_room_confirm_removal_reason_placeholder_android">"Reason…"</string>
<string name="screen_room_confirm_removal_reason_supporting_text">"(Optional)"</string>
<string name="screen_room_confirm_removal_title">"Confirm removal"</string>
<string name="screen_room_encrypted_history_banner">"Message history is currently unavailable."</string>
<string name="screen_room_encrypted_history_banner_unverified">"Message history is unavailable in this room. Verify this device to see your message history."</string>
<string name="screen_room_invite_again_alert_message">"Would you like to invite them back?"</string>
@@ -45,6 +45,7 @@ import io.element.android.features.messages.test.timeline.voicemessages.composer
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@@ -78,10 +79,12 @@ import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.A_REASON
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.core.FakeSendHandle
@@ -125,7 +128,6 @@ import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass")
class MessagesPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -557,13 +559,86 @@ class MessagesPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent()
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, messageEvent))
awaitItem()
advanceUntilIdle()
val confirmingState = expectMostRecentItem()
assertThat(confirmingState.redactEventAction).isEqualTo(MessagesState.ConfirmingRedaction(messageEvent.eventId!!))
assert(redactEventLambda).isNeverCalled()
confirmingState.eventSink(MessagesEvent.ConfirmRedact(A_REASON))
advanceUntilIdle()
assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized)
assert(redactEventLambda)
.isCalledOnce()
.with(value(messageEvent.eventOrTransactionId), value(A_REASON))
}
}
@Test
fun `present - handle action redact - a blank reason is not sent`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
val liveTimeline = FakeTimeline()
val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) }
liveTimeline.redactEventLambda = redactEventLambda
val presenter = createMessagesPresenter(
timeline = liveTimeline,
coroutineDispatchers = coroutineDispatchers,
)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
val messageEvent = aMessageEvent()
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, messageEvent))
advanceUntilIdle()
expectMostRecentItem().eventSink(MessagesEvent.ConfirmRedact(" "))
advanceUntilIdle()
assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized)
assert(redactEventLambda)
.isCalledOnce()
.with(value(messageEvent.eventOrTransactionId), value(null))
}
}
@Test
fun `present - handle action redact - cancelling does not redact`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
val liveTimeline = FakeTimeline()
val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) }
liveTimeline.redactEventLambda = redactEventLambda
val presenter = createMessagesPresenter(
timeline = liveTimeline,
coroutineDispatchers = coroutineDispatchers,
)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, aMessageEvent()))
advanceUntilIdle()
expectMostRecentItem().eventSink(MessagesEvent.CancelRedact)
advanceUntilIdle()
assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized)
assert(redactEventLambda).isNeverCalled()
}
}
@Test
fun `present - handle action redact - a message which was never sent is redacted without asking`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
val liveTimeline = FakeTimeline()
val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) }
liveTimeline.redactEventLambda = redactEventLambda
val presenter = createMessagesPresenter(
timeline = liveTimeline,
coroutineDispatchers = coroutineDispatchers,
)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
val localEcho = aMessageEvent(eventId = null, transactionId = A_TRANSACTION_ID)
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, localEcho))
advanceUntilIdle()
assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized)
assert(redactEventLambda)
.isCalledOnce()
.with(value(localEcho.eventOrTransactionId), value(null))
}
}
@Test
fun `present - handle action report content`() = runTest {
val onReportContentClickLambda = lambdaRecorder { _: EventId, _: UserId -> }
@@ -1484,7 +1559,12 @@ class MessagesPresenterTest {
clipboardHelper = clipboardHelper,
htmlConverterProvider = FakeHtmlConverterProvider(),
buildMeta = aBuildMeta(),
timelineController = TimelineController(joinedRoom, timeline),
timelineController = TimelineController(
room = joinedRoom,
liveTimeline = timeline,
roomCoroutineScope = backgroundScope,
dispatchers = coroutineDispatchers,
),
permalinkParser = permalinkParser,
analyticsService = analyticsService,
encryptionService = encryptionService,
@@ -90,7 +90,6 @@ import org.junit.Test
import org.robolectric.annotation.Config
import kotlin.time.Duration.Companion.milliseconds
@Suppress("LargeClass")
class MessagesViewTest : RobolectricTest() {
@Test
fun `clicking on back invoke expected callback`() = runAndroidComposeUiTest {
@@ -54,7 +54,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@Suppress("LargeClass")
class ActionListPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -82,7 +82,6 @@ import org.junit.Test
import java.io.File
import kotlin.io.path.createTempFile
@Suppress("LargeClass")
class AttachmentsPreviewPresenterTest : RobolectricTest() {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -65,6 +65,7 @@ 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.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
@@ -307,7 +308,12 @@ class MessageComposerPresenterSlashCommandTest {
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = permalinkParser,
permalinkBuilder = permalinkBuilder,
timelineController = TimelineController(room, timeline),
timelineController = TimelineController(
room = room,
liveTimeline = timeline,
roomCoroutineScope = backgroundScope,
dispatchers = testCoroutineDispatchers(),
),
draftService = draftService,
mentionSpanProvider = mentionSpanProvider,
pillificationHelper = textPillificationHelper,
@@ -113,6 +113,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.element.android.tests.testutils.waitForPredicate
import io.mockk.mockk
import kotlinx.collections.immutable.ImmutableList
@@ -126,7 +127,6 @@ import org.junit.Test
import uniffi.wysiwyg_composer.MentionsState
import java.io.File
@Suppress("LargeClass")
class MessageComposerPresenterTest : RobolectricTest() {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -1707,7 +1707,12 @@ class MessageComposerPresenterTest : RobolectricTest() {
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = permalinkParser,
permalinkBuilder = permalinkBuilder,
timelineController = TimelineController(room, timeline),
timelineController = TimelineController(
room = room,
liveTimeline = timeline,
roomCoroutineScope = backgroundScope,
dispatchers = testCoroutineDispatchers(),
),
draftService = draftService,
mentionSpanProvider = mentionSpanProvider,
pillificationHelper = textPillificationHelper,
@@ -21,8 +21,10 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -35,7 +37,10 @@ class TimelineControllerTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) }
)
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline)
val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
sut.activeTimelineFlow().test {
awaitItem().also { state ->
@@ -74,7 +79,10 @@ class TimelineControllerTest {
}
}
)
val sut = TimelineController(joinedRoom, liveTimeline)
val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
sut.activeTimelineFlow().test {
awaitItem().also { state ->
@@ -102,7 +110,10 @@ class TimelineControllerTest {
val joinedRoom = FakeJoinedRoom(
liveTimeline = liveTimeline
)
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline)
val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
sut.activeTimelineFlow().test {
awaitItem().also { state ->
assertThat(state).isEqualTo(liveTimeline)
@@ -121,7 +132,10 @@ class TimelineControllerTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) }
)
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline)
val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
sut.activeTimelineFlow().test {
awaitItem().also { state ->
assertThat(state).isEqualTo(liveTimeline)
@@ -149,7 +163,10 @@ class TimelineControllerTest {
val joinedRoom = FakeJoinedRoom(
liveTimeline = liveTimeline
)
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline)
val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
assertThat(sut.timelineItems().first()).hasSize(1)
}
@@ -171,7 +188,10 @@ class TimelineControllerTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) }
)
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline)
val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
sut.activeTimelineFlow().test {
sut.focusOnEvent(AN_EVENT_ID, null)
awaitItem().also { state ->
@@ -196,7 +216,10 @@ class TimelineControllerTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) }
)
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline)
val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
sut.activeTimelineFlow().test {
awaitItem().also { state ->
@@ -220,12 +243,14 @@ class TimelineControllerTest {
}
}
internal fun createTimelineController(
internal fun TestScope.createTimelineController(
room: FakeJoinedRoom = FakeJoinedRoom(liveTimeline = FakeTimeline()),
liveTimeline: Timeline = FakeTimeline(name = "live"),
): TimelineController {
return TimelineController(
room = room,
liveTimeline = liveTimeline
liveTimeline = liveTimeline,
roomCoroutineScope = backgroundScope,
dispatchers = testCoroutineDispatchers(),
)
}
@@ -103,7 +103,6 @@ import java.util.Date
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass")
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
class TimelinePresenterTest {
@get:Rule
@@ -1079,7 +1078,11 @@ class TimelinePresenterTest {
val focusedState = consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }.last()
assertThat(focusedState.isLive).isFalse()
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
advanceUntilIdle()
// Give some time for any potential pagination to be called, but it should not be called because the focused event is not rendered yet.
// Also, given enough time, a new state should be emitted with isLive = false, because we switched to a focused event timeline.
runCurrent()
assert(paginateLambda).isNeverCalled()
assertThat(expectMostRecentItem().isLive).isFalse()
}
@@ -1906,7 +1909,12 @@ class TimelinePresenterTest {
sendPollResponseAction = sendPollResponseAction,
sessionPreferencesStore = sessionPreferencesStore,
timelineItemIndexer = timelineItemIndexer,
timelineController = TimelineController(room, timeline),
timelineController = TimelineController(
room = room,
liveTimeline = timeline,
roomCoroutineScope = backgroundScope,
dispatchers = testCoroutineDispatchers(),
),
resolveVerifiedUserSendFailurePresenter = resolveVerifiedUserSendFailurePresenter,
typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() },
@@ -83,7 +83,6 @@ import org.junit.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
@Suppress("LargeClass")
class TimelineItemContentMessageFactoryTest : RobolectricTest() {
@Test
fun `test create OtherMessageType`() = runTest {
@@ -30,7 +30,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@Suppress("LargeClass")
class TypingNotificationPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -63,7 +63,6 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass")
class DefaultVoiceMessageComposerPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -104,6 +103,7 @@ class DefaultVoiceMessageComposerPresenterTest {
companion object {
private val RECORDING_DURATION = 1.seconds
private val PLAYER_DURATION = 10.seconds
private val FIRST_LEVEL_DURATION = 500.milliseconds
private val RECORDING_STATE = VoiceMessageState.Recording(RECORDING_DURATION, listOf(0.1f, 0.2f).toImmutableList())
private val FIRST_RECORDING_STATE = VoiceMessageState.Recording(FIRST_LEVEL_DURATION, listOf(0.1f).toImmutableList())
@@ -267,6 +267,7 @@ class DefaultVoiceMessageComposerPresenterTest {
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState())
assertThat((finalState.voiceMessageState as VoiceMessageState.Preview).duration).isEqualTo(RECORDING_DURATION)
startRecordResult.assertions().isCalledOnce()
stopRecordResult.assertions().isCalledOnce().with(value(false))
deleteRecordingResult.assertions().isNeverCalled()
@@ -303,6 +304,7 @@ class DefaultVoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play))
val finalState = awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(aPlayingState())
assertThat((it.voiceMessageState as VoiceMessageState.Preview).duration).isEqualTo(PLAYER_DURATION)
}
startRecordResult.assertions().isCalledOnce()
stopRecordResult.assertions().isCalledOnce().with(value(false))
@@ -339,17 +341,30 @@ class DefaultVoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.5f)))
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true))
assertThat(voiceMessageState).isEqualTo(
aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true)
)
}
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 5.seconds, showCursor = true))
assertThat(voiceMessageState).isEqualTo(
aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true, duration = PLAYER_DURATION)
)
}
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(
aPreviewState(playbackProgress = 0.5f, time = 5.seconds, showCursor = true, duration = PLAYER_DURATION)
)
eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.2f)))
}
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 5.seconds, showCursor = true))
assertThat(voiceMessageState).isEqualTo(
aPreviewState(playbackProgress = 0.2f, time = 5.seconds, showCursor = true, duration = PLAYER_DURATION)
)
}
val finalState = awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 2.seconds, showCursor = true))
assertThat(voiceMessageState).isEqualTo(
aPreviewState(playbackProgress = 0.2f, time = 2.seconds, showCursor = true, duration = PLAYER_DURATION)
)
}
testPauseAndDestroy(finalState)
@@ -828,6 +843,7 @@ class DefaultVoiceMessageComposerPresenterTest {
playbackProgress: Float = 0f,
isSending: Boolean = false,
time: Duration = RECORDING_DURATION,
duration: Duration = RECORDING_DURATION,
showCursor: Boolean = false,
waveform: List<Float> = voiceRecorder.waveform,
) = VoiceMessageState.Preview(
@@ -835,6 +851,7 @@ class DefaultVoiceMessageComposerPresenterTest {
playbackProgress = playbackProgress,
isSending = isSending,
time = time,
duration = duration,
showCursor = showCursor,
waveform = waveform.toImmutableList(),
)
@@ -845,6 +862,7 @@ class DefaultVoiceMessageComposerPresenterTest {
playbackProgress = 0.1f,
showCursor = true,
time = RECORDING_DURATION,
duration = PLAYER_DURATION,
)
private fun aPausedState() =