mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-05 04:15:12 +00:00
Merge branch 'develop' into feature/fga/timeline-multi-forward
This commit is contained in:
@@ -45,6 +45,7 @@ dependencies {
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.dateformatter.api)
|
||||
implementation(projects.libraries.eventformatter.api)
|
||||
implementation(projects.libraries.htmlrenderer.api)
|
||||
implementation(projects.libraries.mediapickers.api)
|
||||
implementation(projects.libraries.mediaviewer.api)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
@@ -59,6 +60,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)
|
||||
@@ -110,4 +112,5 @@ dependencies {
|
||||
testImplementation(projects.libraries.emoji.impl)
|
||||
testImplementation(projects.libraries.emoji.test)
|
||||
testImplementation(projects.libraries.slashcommands.test)
|
||||
testImplementation(projects.libraries.htmlrenderer.test)
|
||||
}
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+12
-12
@@ -87,6 +87,7 @@ import io.element.android.libraries.matrix.api.room.alias.matches
|
||||
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
|
||||
import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
|
||||
@@ -104,6 +105,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -180,7 +182,8 @@ class MessagesFlowNode(
|
||||
@Parcelize
|
||||
data class ForwardEvent(
|
||||
val eventIds: List<EventId>,
|
||||
val fromPinnedEvents: Boolean,
|
||||
@IgnoredOnParcel
|
||||
val timelineProvider: TimelineProvider? = null,
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
@@ -303,8 +306,8 @@ class MessagesFlowNode(
|
||||
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
|
||||
}
|
||||
|
||||
override fun forwardEvents(eventIds: List<EventId>) {
|
||||
backstack.push(NavTarget.ForwardEvent(eventIds, fromPinnedEvents = false))
|
||||
override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
|
||||
backstack.push(NavTarget.ForwardEvent(eventIds, timelineProvider))
|
||||
}
|
||||
|
||||
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
|
||||
@@ -459,11 +462,8 @@ class MessagesFlowNode(
|
||||
createNode<EventDebugInfoNode>(buildContext, listOf(inputs))
|
||||
}
|
||||
is NavTarget.ForwardEvent -> {
|
||||
val timelineProvider = if (navTarget.fromPinnedEvents) {
|
||||
pinnedEventsTimelineProvider
|
||||
} else {
|
||||
timelineController
|
||||
}
|
||||
// If no timeline provider is received, assume the live timeline should be used
|
||||
val timelineProvider = navTarget.timelineProvider ?: timelineController
|
||||
val params = ForwardEntryPoint.Params(navTarget.eventIds, timelineProvider)
|
||||
val callback = object : ForwardEntryPoint.Callback {
|
||||
override fun onDone(roomIds: List<RoomId>) {
|
||||
@@ -550,8 +550,8 @@ class MessagesFlowNode(
|
||||
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
|
||||
}
|
||||
|
||||
override fun handleForwardEventClick(eventId: EventId) {
|
||||
backstack.push(NavTarget.ForwardEvent(eventIds = listOf(eventId), fromPinnedEvents = true))
|
||||
override fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) {
|
||||
backstack.push(NavTarget.ForwardEvent(eventIds = listOf(eventId), timelineProvider = timelineProvider))
|
||||
}
|
||||
|
||||
override fun navigateToThread(threadRootId: ThreadId) {
|
||||
@@ -617,8 +617,8 @@ class MessagesFlowNode(
|
||||
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
|
||||
}
|
||||
|
||||
override fun handleForwardEventClick(eventIds: List<EventId>) {
|
||||
backstack.push(NavTarget.ForwardEvent(eventIds, fromPinnedEvents = false))
|
||||
override fun handleForwardEventClick(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
|
||||
backstack.push(NavTarget.ForwardEvent(eventIds, timelineProvider))
|
||||
}
|
||||
|
||||
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
|
||||
|
||||
+2
-1
@@ -13,12 +13,13 @@ 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.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
interface MessagesNavigator {
|
||||
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||
fun forwardEvents(eventIds: List<EventId>)
|
||||
fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider)
|
||||
fun navigateToReportMessage(eventId: EventId, senderId: UserId)
|
||||
fun navigateToEditPoll(eventId: EventId)
|
||||
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
|
||||
|
||||
+12
-4
@@ -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
|
||||
@@ -67,6 +68,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.alias.matches
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
|
||||
@@ -103,6 +105,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 +114,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),
|
||||
@@ -130,7 +138,7 @@ class MessagesNode(
|
||||
fun navigateToRoomMemberDetails(userId: UserId)
|
||||
fun handlePermalinkClick(data: PermalinkData)
|
||||
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||
fun forwardEvents(eventIds: List<EventId>)
|
||||
fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider)
|
||||
fun navigateToReportMessage(eventId: EventId, senderId: UserId)
|
||||
fun navigateToSendLocation()
|
||||
fun navigateToCreatePoll()
|
||||
@@ -213,8 +221,8 @@ class MessagesNode(
|
||||
callback.navigateToEventDebugInfo(eventId, debugInfo)
|
||||
}
|
||||
|
||||
override fun forwardEvents(eventIds: List<EventId>) {
|
||||
callback.forwardEvents(eventIds)
|
||||
override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
|
||||
callback.forwardEvents(eventIds, timelineProvider)
|
||||
}
|
||||
|
||||
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
|
||||
|
||||
+35
-6
@@ -61,6 +61,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
|
||||
@@ -87,6 +88,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
|
||||
@@ -179,7 +181,7 @@ class MessagesPresenter(
|
||||
.collectLatest { value = it.toImmutableList() }
|
||||
}
|
||||
|
||||
val canOpenThreadList by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomThreadList).collectAsState(initial = false)
|
||||
val canOpenThreadList by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(initial = false)
|
||||
val isCurrentlySharingLiveLocationInRoom by remember { liveLocationShareManager.isCurrentlySharing(room.roomId) }.collectAsState()
|
||||
|
||||
val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
|
||||
@@ -244,6 +246,8 @@ class MessagesPresenter(
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
val redactEventAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||
|
||||
fun handleEvent(event: MessagesEvent) {
|
||||
when (event) {
|
||||
is MessagesEvent.HandleAction -> {
|
||||
@@ -254,6 +258,7 @@ class MessagesPresenter(
|
||||
enableTextFormatting = composerState.showTextFormatting,
|
||||
timelineState = timelineState,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
redactEventAction = redactEventAction,
|
||||
)
|
||||
}
|
||||
is MessagesEvent.ConfirmSelectionAction -> {
|
||||
@@ -261,12 +266,24 @@ class MessagesPresenter(
|
||||
if (selection != null) {
|
||||
when (selection.action) {
|
||||
SelectionAction.Forward -> {
|
||||
navigator.forwardEvents(timelineState.selectedEventIdsInTimelineOrder())
|
||||
navigator.forwardEvents(timelineState.selectedEventIdsInTimelineOrder(), timelineController)
|
||||
}
|
||||
}
|
||||
timelineState.eventSink(TimelineEvent.ExitSelectionMode)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -345,6 +362,7 @@ class MessagesPresenter(
|
||||
hasUnreadThreads = false,
|
||||
),
|
||||
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom && timelineState.timelineMode !is Timeline.Mode.Thread,
|
||||
redactEventAction = redactEventAction.value,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
@@ -383,12 +401,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)
|
||||
@@ -414,7 +433,7 @@ class MessagesPresenter(
|
||||
if (featureFlagService.isFeatureEnabled(FeatureFlags.MessageMultiSelect)) {
|
||||
timelineState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, eventId))
|
||||
} else {
|
||||
navigator.forwardEvents(listOf(eventId))
|
||||
navigator.forwardEvents(listOf(eventId), timelineController)
|
||||
}
|
||||
}
|
||||
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
|
||||
@@ -517,9 +536,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) }
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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. */
|
||||
|
||||
+5
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+14
@@ -108,6 +108,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
|
||||
@@ -483,6 +484,19 @@ fun MessagesView(
|
||||
state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog)
|
||||
},
|
||||
)
|
||||
|
||||
if (state.redactEventAction is MessagesState.ConfirmingRedaction) {
|
||||
TextFieldDialog(
|
||||
title = stringResource(R.string.screen_room_confirm_removal_title),
|
||||
placeholder = stringResource(R.string.screen_room_confirm_removal_reason_placeholder),
|
||||
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
|
||||
|
||||
+2
-1
@@ -10,11 +10,12 @@ package io.element.android.features.messages.impl.pinned.list
|
||||
|
||||
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.timeline.TimelineProvider
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
|
||||
interface PinnedMessagesListNavigator {
|
||||
fun viewInTimeline(eventId: EventId)
|
||||
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||
fun forwardEvent(eventId: EventId)
|
||||
fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider)
|
||||
fun navigateToThread(threadRootId: ThreadId)
|
||||
}
|
||||
|
||||
+4
-3
@@ -37,6 +37,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.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard
|
||||
@@ -60,7 +61,7 @@ class PinnedMessagesListNode(
|
||||
fun viewInTimeline(eventId: EventId)
|
||||
fun handlePermalinkClick(data: PermalinkData.RoomLink)
|
||||
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||
fun handleForwardEventClick(eventId: EventId)
|
||||
fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider)
|
||||
fun navigateToThread(threadRootId: ThreadId)
|
||||
}
|
||||
|
||||
@@ -98,8 +99,8 @@ class PinnedMessagesListNode(
|
||||
callback.navigateToEventDebugInfo(eventId, debugInfo)
|
||||
}
|
||||
|
||||
override fun forwardEvent(eventId: EventId) {
|
||||
callback.handleForwardEventClick(eventId)
|
||||
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) {
|
||||
callback.handleForwardEventClick(eventId, timelineProvider)
|
||||
}
|
||||
|
||||
override fun navigateToThread(threadRootId: ThreadId) {
|
||||
|
||||
+2
-1
@@ -107,6 +107,7 @@ class PinnedMessagesListPresenter(
|
||||
val timelineRoomInfo by remember {
|
||||
derivedStateOf {
|
||||
TimelineRoomInfo(
|
||||
currentUserId = room.sessionId,
|
||||
isDm = roomInfo.isDm,
|
||||
name = roomInfo.name,
|
||||
// We don't need to compute those values
|
||||
@@ -180,7 +181,7 @@ class PinnedMessagesListPresenter(
|
||||
}
|
||||
TimelineItemAction.Forward -> {
|
||||
targetEvent.eventId?.let { eventId ->
|
||||
navigator.forwardEvent(eventId)
|
||||
navigator.forwardEvent(eventId, timelineProvider)
|
||||
}
|
||||
}
|
||||
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
|
||||
|
||||
+1
-1
@@ -37,7 +37,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
|
||||
@@ -59,6 +58,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
|
||||
|
||||
+12
-4
@@ -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
|
||||
@@ -69,6 +70,7 @@ import io.element.android.libraries.matrix.api.room.CreateTimelineParams
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.alias.matches
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.ui.model.getBestName
|
||||
import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard
|
||||
@@ -95,6 +97,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 +116,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,
|
||||
@@ -135,7 +143,7 @@ class ThreadedMessagesNode(
|
||||
fun navigateToRoomMemberDetails(userId: UserId)
|
||||
fun handlePermalinkClick(data: PermalinkData)
|
||||
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||
fun handleForwardEventClick(eventIds: List<EventId>)
|
||||
fun handleForwardEventClick(eventIds: List<EventId>, timelineProvider: TimelineProvider)
|
||||
fun navigateToReportMessage(eventId: EventId, senderId: UserId)
|
||||
fun navigateToSendLocation()
|
||||
fun navigateToCreatePoll()
|
||||
@@ -219,8 +227,8 @@ class ThreadedMessagesNode(
|
||||
callback.navigateToEventDebugInfo(eventId, debugInfo)
|
||||
}
|
||||
|
||||
override fun forwardEvents(eventIds: List<EventId>) {
|
||||
callback.handleForwardEventClick(eventIds)
|
||||
override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
|
||||
callback.handleForwardEventClick(eventIds, timelineProvider)
|
||||
}
|
||||
|
||||
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
/**
|
||||
* Local configuration for the timeline event renderer, so it can be accessed from UI components without having to pass it down through parameters.
|
||||
*/
|
||||
val LocalTimelineEventRendererConfig = staticCompositionLocalOf {
|
||||
ComposeLocalTimelineEventRendererConfig(
|
||||
isComposeRendererEnabled = false,
|
||||
currentUserId = null,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the timeline event renderer.
|
||||
*/
|
||||
data class ComposeLocalTimelineEventRendererConfig(
|
||||
/** Whether the compose renderer is enabled or not. If `false` (the default option), the view-based renderer will be used instead. */
|
||||
val isComposeRendererEnabled: Boolean,
|
||||
/** The current user ID, if known. This can be used to display UI sent by/owned by the current user differently. */
|
||||
val currentUserId: UserId?,
|
||||
)
|
||||
+6
-2
@@ -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())
|
||||
|
||||
+5
@@ -170,6 +170,9 @@ class TimelinePresenter(
|
||||
val displayJumpToUnread by produceState(false) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
|
||||
}
|
||||
val useNewTimelineEventRenderer by produceState(false) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.NewTimelineEventRenderer)
|
||||
}
|
||||
|
||||
val isMultiSelectEnabled by produceState(false) {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageMultiSelect).collect { value = it }
|
||||
@@ -495,6 +498,7 @@ class TimelinePresenter(
|
||||
val timelineRoomInfo by remember(typingNotificationState, roomCallState, roomInfo) {
|
||||
derivedStateOf {
|
||||
TimelineRoomInfo(
|
||||
currentUserId = room.sessionId,
|
||||
name = roomInfo.name,
|
||||
isDm = roomInfo.isDm,
|
||||
userHasPermissionToSendMessage = userEventPermissions.canSendMessage,
|
||||
@@ -525,6 +529,7 @@ class TimelinePresenter(
|
||||
displayJumpToUnread = displayJumpToUnread,
|
||||
jumpToUnread = jumpToUnread.value,
|
||||
selectionState = selectionState,
|
||||
useNewTimelineEventRenderer = useNewTimelineEventRenderer,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
+4
@@ -18,6 +18,7 @@ import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||
import io.element.android.features.roomcall.api.RoomCallState
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -40,6 +41,8 @@ data class TimelineState(
|
||||
val displayJumpToUnread: Boolean,
|
||||
val jumpToUnread: JumpToUnreadState,
|
||||
val selectionState: SelectionState,
|
||||
// When true, formatted message bodies are rendered with the native Compose renderer.
|
||||
val useNewTimelineEventRenderer: Boolean,
|
||||
val eventSink: (TimelineEvent) -> Unit,
|
||||
) {
|
||||
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event
|
||||
@@ -79,6 +82,7 @@ sealed interface FocusRequestState {
|
||||
}
|
||||
|
||||
data class TimelineRoomInfo(
|
||||
val currentUserId: UserId,
|
||||
val isDm: Boolean,
|
||||
val name: String?,
|
||||
val userHasPermissionToSendMessage: Boolean,
|
||||
|
||||
+4
@@ -64,6 +64,7 @@ fun aTimelineState(
|
||||
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
|
||||
newEventState: NewEventState = NewEventState.None,
|
||||
selectionState: SelectionState = SelectionState.Disabled,
|
||||
useNewTimelineEventRenderer: Boolean = false,
|
||||
eventSink: (TimelineEvent) -> Unit = {},
|
||||
): TimelineState {
|
||||
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
|
||||
@@ -86,6 +87,7 @@ fun aTimelineState(
|
||||
displayJumpToUnread = displayJumpToUnread,
|
||||
jumpToUnread = jumpToUnread,
|
||||
selectionState = selectionState,
|
||||
useNewTimelineEventRenderer = useNewTimelineEventRenderer,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
@@ -279,6 +281,7 @@ internal fun aRedactedMessagesGroupedEvents(
|
||||
}
|
||||
|
||||
internal fun aTimelineRoomInfo(
|
||||
currentUserId: UserId = UserId("@user:domain"),
|
||||
name: String = ROOM_NAME,
|
||||
isDm: Boolean = false,
|
||||
userHasPermissionToSendMessage: Boolean = true,
|
||||
@@ -286,6 +289,7 @@ internal fun aTimelineRoomInfo(
|
||||
typingNotificationState: TypingNotificationState = aTypingNotificationState(),
|
||||
predecessorRoom: PredecessorRoom? = null,
|
||||
) = TimelineRoomInfo(
|
||||
currentUserId = currentUserId,
|
||||
isDm = isDm,
|
||||
name = name,
|
||||
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
||||
|
||||
+90
-83
@@ -101,7 +101,6 @@ import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAto
|
||||
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
|
||||
import io.element.android.libraries.designsystem.text.roundToPx
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter
|
||||
@@ -207,90 +206,98 @@ fun TimelineView(
|
||||
|
||||
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
|
||||
AnimatedVisibility(visible = true, enter = fadeIn()) {
|
||||
Box(modifier) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
.testTag(TestTags.timeline),
|
||||
state = lazyListState,
|
||||
reverseLayout = true,
|
||||
contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues() + PaddingValues(top = 64.dp, bottom = 8.dp),
|
||||
) {
|
||||
items(
|
||||
items = state.timelineItems,
|
||||
contentType = { timelineItem -> timelineItem.contentType() },
|
||||
key = { timelineItem -> timelineItem.identifier() },
|
||||
) { timelineItem ->
|
||||
TimelineItemRow(
|
||||
timelineItem = timelineItem,
|
||||
timelineMode = state.timelineMode,
|
||||
timelineRoomInfo = state.timelineRoomInfo,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
|
||||
focusedEventId = state.focusedEventId,
|
||||
displayThreadSummaries = state.displayThreadSummaries,
|
||||
selectionData = TimelineItemSelectionData(
|
||||
isSelectionModeActive = isSelectionModeActive,
|
||||
progress = selectionProgress,
|
||||
isSelected = state.isSelected(timelineItem),
|
||||
canBeSelected = state.canSelect(timelineItem),
|
||||
),
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = ::onLinkLongClick,
|
||||
onContentClick = { event ->
|
||||
if (state.canSelect(event)) onToggleSelection(event) else onContentClick(event)
|
||||
},
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onLongClick = { event ->
|
||||
if (state.canSelect(event)) onToggleSelection(event) else onMessageLongClick(event)
|
||||
},
|
||||
inReplyToClick = ::inReplyToClick,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
eventSink = state.eventSink,
|
||||
)
|
||||
val composeLocalTimelineEventRendererConfig = remember(state.useNewTimelineEventRenderer, state.timelineRoomInfo.currentUserId) {
|
||||
ComposeLocalTimelineEventRendererConfig(
|
||||
isComposeRendererEnabled = state.useNewTimelineEventRenderer,
|
||||
currentUserId = state.timelineRoomInfo.currentUserId,
|
||||
)
|
||||
}
|
||||
CompositionLocalProvider(LocalTimelineEventRendererConfig provides composeLocalTimelineEventRendererConfig) {
|
||||
Box(modifier) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
.testTag(TestTags.timeline),
|
||||
state = lazyListState,
|
||||
reverseLayout = true,
|
||||
contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues() + PaddingValues(top = 64.dp, bottom = 8.dp),
|
||||
) {
|
||||
items(
|
||||
items = state.timelineItems,
|
||||
contentType = { timelineItem -> timelineItem.contentType() },
|
||||
key = { timelineItem -> timelineItem.identifier() },
|
||||
) { timelineItem ->
|
||||
TimelineItemRow(
|
||||
timelineItem = timelineItem,
|
||||
timelineMode = state.timelineMode,
|
||||
timelineRoomInfo = state.timelineRoomInfo,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
|
||||
focusedEventId = state.focusedEventId,
|
||||
displayThreadSummaries = state.displayThreadSummaries,
|
||||
selectionData = TimelineItemSelectionData(
|
||||
isSelectionModeActive = isSelectionModeActive,
|
||||
progress = selectionProgress,
|
||||
isSelected = state.isSelected(timelineItem),
|
||||
canBeSelected = state.canSelect(timelineItem),
|
||||
),
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = ::onLinkLongClick,
|
||||
onContentClick = { event ->
|
||||
if (state.canSelect(event)) onToggleSelection(event) else onContentClick(event)
|
||||
},
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onLongClick = { event ->
|
||||
if (state.canSelect(event)) onToggleSelection(event) else onMessageLongClick(event)
|
||||
},
|
||||
inReplyToClick = ::inReplyToClick,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
eventSink = state.eventSink,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FocusRequestStateView(
|
||||
focusRequestState = state.focusRequestState,
|
||||
onClearFocusRequestState = ::clearFocusRequestState
|
||||
)
|
||||
|
||||
TimelinePrefetchingHelper(
|
||||
lazyListState = lazyListState,
|
||||
prefetch = ::prefetchMoreItems
|
||||
)
|
||||
|
||||
TimelineScrollHelper(
|
||||
hasAnyEvent = state.hasAnyEvent,
|
||||
lazyListState = lazyListState,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
forceJumpToReadMarkerVisibility = forceJumpToReadMarkerVisibility,
|
||||
newEventState = state.newEventState,
|
||||
isLive = state.isLive,
|
||||
focusRequestState = state.focusRequestState,
|
||||
displayJumpToUnread = state.displayJumpToUnread,
|
||||
jumpToUnread = state.jumpToUnread,
|
||||
onScrollFinishAt = ::onScrollFinishAt,
|
||||
onJumpToLive = ::onJumpToLive,
|
||||
onFocusEventRender = ::onFocusEventRender,
|
||||
onMarkAllAsRead = ::onMarkAllAsRead,
|
||||
onFocusOnEvent = ::onFocusOnEvent,
|
||||
)
|
||||
|
||||
FloatingDateBadgeOverlay(
|
||||
lazyListState = lazyListState,
|
||||
timelineItems = state.timelineItems,
|
||||
isLive = state.isLive,
|
||||
topOffset = floatingDateTopOffset,
|
||||
)
|
||||
}
|
||||
|
||||
FocusRequestStateView(
|
||||
focusRequestState = state.focusRequestState,
|
||||
onClearFocusRequestState = ::clearFocusRequestState
|
||||
)
|
||||
|
||||
TimelinePrefetchingHelper(
|
||||
lazyListState = lazyListState,
|
||||
prefetch = ::prefetchMoreItems
|
||||
)
|
||||
|
||||
TimelineScrollHelper(
|
||||
hasAnyEvent = state.hasAnyEvent,
|
||||
lazyListState = lazyListState,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
forceJumpToReadMarkerVisibility = forceJumpToReadMarkerVisibility,
|
||||
newEventState = state.newEventState,
|
||||
isLive = state.isLive,
|
||||
focusRequestState = state.focusRequestState,
|
||||
displayJumpToUnread = state.displayJumpToUnread,
|
||||
jumpToUnread = state.jumpToUnread,
|
||||
onScrollFinishAt = ::onScrollFinishAt,
|
||||
onJumpToLive = ::onJumpToLive,
|
||||
onFocusEventRender = ::onFocusEventRender,
|
||||
onMarkAllAsRead = ::onMarkAllAsRead,
|
||||
onFocusOnEvent = ::onFocusOnEvent,
|
||||
)
|
||||
|
||||
FloatingDateBadgeOverlay(
|
||||
lazyListState = lazyListState,
|
||||
timelineItems = state.timelineItems,
|
||||
isLive = state.isLive,
|
||||
topOffset = floatingDateTopOffset,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -74,8 +74,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
|
||||
@@ -141,6 +139,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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -39,7 +39,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
|
||||
@@ -56,6 +55,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
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+29
-2
@@ -23,8 +23,7 @@ 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.LocalTimelineEventRendererConfig
|
||||
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
|
||||
@@ -34,8 +33,11 @@ import io.element.android.libraries.androidutils.text.LinkifyHelper
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
|
||||
import io.element.android.libraries.htmlrenderer.api.ui.HtmlMessageContent
|
||||
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
|
||||
|
||||
@@ -50,6 +52,31 @@ fun TimelineItemTextView(
|
||||
// The View <-> Compose interop is not working well with Compose UI tests (it loops indefinitely), so we skip it in the UI test mode.
|
||||
if (LocalUiTestMode.current) return
|
||||
|
||||
// When the new timeline event renderer is enabled and the message has a parsed tree, render it
|
||||
// natively with Compose. Otherwise fall back to the legacy rich text editor view below.
|
||||
val messageTree = content.messageTree
|
||||
if (LocalTimelineEventRendererConfig.current.isComposeRendererEnabled && messageTree != null) {
|
||||
Box(modifier.semantics { contentDescription = content.plainText }) {
|
||||
HtmlMessageContent(
|
||||
node = messageTree,
|
||||
currentUserId = LocalTimelineEventRendererConfig.current.currentUserId,
|
||||
onLinkClick = { url, text -> onLinkClick(Link(url = url, text = text)) },
|
||||
onLinkLongClick = { url, text -> onLinkLongClick(Link(url = url, text = text)) },
|
||||
onContentLayoutChange = { data ->
|
||||
onContentLayoutChange(
|
||||
ContentAvoidingLayoutData(
|
||||
contentWidth = data.contentWidth,
|
||||
contentHeight = data.contentHeight,
|
||||
nonOverlappingContentWidth = data.nonOverlappingContentWidth,
|
||||
nonOverlappingContentHeight = data.nonOverlappingContentHeight,
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val isInPreview = LocalInspectionMode.current
|
||||
val emojiOnly = remember(content.body, content.formattedBody, isInPreview) {
|
||||
content.formattedBody.toString() == content.body &&
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-1
@@ -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)) },
|
||||
)
|
||||
|
||||
-209
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-17
@@ -33,6 +33,9 @@ import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
||||
import io.element.android.libraries.androidutils.text.safeLinkify
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
@@ -71,8 +74,10 @@ class TimelineItemContentMessageFactory(
|
||||
private val htmlConverterProvider: HtmlConverterProvider,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val textPillificationHelper: TextPillificationHelper,
|
||||
private val htmlMessageParser: HtmlMessageParser,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) {
|
||||
fun create(
|
||||
suspend fun create(
|
||||
content: MessageContent,
|
||||
senderId: UserId,
|
||||
senderProfile: ProfileDetails,
|
||||
@@ -93,6 +98,28 @@ class TimelineItemContentMessageFactory(
|
||||
htmlDocument = dom,
|
||||
formattedBody = formattedBody,
|
||||
isEdited = content.isEdited,
|
||||
messageTree = if (featureFlagService.isFeatureEnabled(FeatureFlags.NewTimelineEventRenderer)) {
|
||||
dom?.let(htmlMessageParser::parse)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
is TextMessageType -> {
|
||||
val body = messageType.body.trimEnd()
|
||||
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
|
||||
val formattedBody = htmlDocument?.let(::parseHtml)
|
||||
?: textPillificationHelper.pillify(body).safeLinkify()
|
||||
TimelineItemTextContent(
|
||||
body = body,
|
||||
htmlDocument = htmlDocument,
|
||||
formattedBody = formattedBody,
|
||||
isEdited = content.isEdited,
|
||||
messageTree = if (featureFlagService.isFeatureEnabled(FeatureFlags.NewTimelineEventRenderer)) {
|
||||
htmlDocument?.let(htmlMessageParser::parse)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
is ImageMessageType -> {
|
||||
@@ -251,28 +278,15 @@ class TimelineItemContentMessageFactory(
|
||||
}
|
||||
is NoticeMessageType -> {
|
||||
val body = messageType.body.trimEnd()
|
||||
val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
|
||||
val formattedBody = dom?.let(::parseHtml)
|
||||
?: textPillificationHelper.pillify(body).safeLinkify()
|
||||
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
|
||||
val formattedBody = htmlDocument?.let(::parseHtml)
|
||||
?: textPillificationHelper.pillify(body).safeLinkify()
|
||||
TimelineItemNoticeContent(
|
||||
body = body,
|
||||
htmlDocument = htmlDocument,
|
||||
formattedBody = formattedBody,
|
||||
isEdited = content.isEdited,
|
||||
)
|
||||
}
|
||||
is TextMessageType -> {
|
||||
val body = messageType.body.trimEnd()
|
||||
val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
|
||||
val formattedBody = dom?.let(::parseHtml)
|
||||
?: textPillificationHelper.pillify(body).safeLinkify()
|
||||
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
|
||||
TimelineItemTextContent(
|
||||
body = body,
|
||||
htmlDocument = htmlDocument,
|
||||
formattedBody = formattedBody,
|
||||
isEdited = content.isEdited,
|
||||
messageTree = htmlDocument?.let(htmlMessageParser::parse),
|
||||
)
|
||||
}
|
||||
is GalleryMessageType -> {
|
||||
|
||||
+2
@@ -8,6 +8,7 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.model.event
|
||||
|
||||
import io.element.android.libraries.htmlrenderer.api.DocumentNode
|
||||
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||
import org.jsoup.nodes.Document
|
||||
|
||||
@@ -16,6 +17,7 @@ data class TimelineItemEmoteContent(
|
||||
override val htmlDocument: Document?,
|
||||
override val formattedBody: CharSequence,
|
||||
override val isEdited: Boolean,
|
||||
override val messageTree: DocumentNode? = null,
|
||||
) : TimelineItemTextBasedContent {
|
||||
override val type: String = "TimelineItemEmoteContent"
|
||||
override val plainText: String = htmlDocument?.toPlainText() ?: body
|
||||
|
||||
+2
@@ -8,6 +8,7 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.model.event
|
||||
|
||||
import io.element.android.libraries.htmlrenderer.api.DocumentNode
|
||||
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||
import org.jsoup.nodes.Document
|
||||
|
||||
@@ -16,6 +17,7 @@ data class TimelineItemNoticeContent(
|
||||
override val htmlDocument: Document?,
|
||||
override val formattedBody: CharSequence,
|
||||
override val isEdited: Boolean,
|
||||
override val messageTree: DocumentNode? = null,
|
||||
) : TimelineItemTextBasedContent {
|
||||
override val type: String = "TimelineItemNoticeContent"
|
||||
override val plainText: String = htmlDocument?.toPlainText() ?: body
|
||||
|
||||
+7
@@ -9,6 +9,7 @@
|
||||
package io.element.android.features.messages.impl.timeline.model.event
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.htmlrenderer.api.DocumentNode
|
||||
import org.jsoup.nodes.Document
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,12 @@ sealed interface TimelineItemTextBasedContent :
|
||||
/** The parsed HTML DOM of the formatted event body. */
|
||||
val htmlDocument: Document?
|
||||
|
||||
/**
|
||||
* The formatted body parsed into a tree of [DocumentNode] for the native Compose renderer.
|
||||
* Null when there is no formatted body. Only used when the new timeline event renderer is enabled.
|
||||
*/
|
||||
val messageTree: DocumentNode?
|
||||
|
||||
/** The formatted body of the event, already parsed and with the DOM translated to Android spans.
|
||||
* This can also includes mention spans from permalink parsing */
|
||||
val formattedBody: CharSequence
|
||||
|
||||
+2
@@ -8,6 +8,7 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.model.event
|
||||
|
||||
import io.element.android.libraries.htmlrenderer.api.DocumentNode
|
||||
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||
import org.jsoup.nodes.Document
|
||||
|
||||
@@ -16,6 +17,7 @@ data class TimelineItemTextContent(
|
||||
override val htmlDocument: Document?,
|
||||
override val formattedBody: CharSequence,
|
||||
override val isEdited: Boolean,
|
||||
override val messageTree: DocumentNode? = null,
|
||||
) : TimelineItemTextBasedContent {
|
||||
override val type: String = "TimelineItemTextContent"
|
||||
override val plainText: String = htmlDocument?.toPlainText() ?: body
|
||||
|
||||
+4
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+4
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,19 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Šifrováno neznámým nebo smazaným zařízením."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Šifrováno zařízením, které nebylo ověřeno jeho vlastníkem."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Šifrováno neověřeným uživatelem."</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Otočit obrázek vodorovně"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Otočeno vodorovně"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Originál"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically">"Otočit obrázek svisle"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Otočeno svisle "</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Originál"</string>
|
||||
<string name="screen_image_edition_a11y_rotate_to_the_left">"Otočit obrázek doleva"</string>
|
||||
<plurals name="screen_image_edition_a11y_rotation_state">
|
||||
<item quantity="one">"%1$d stupeň"</item>
|
||||
<item quantity="few">"%1$d stupně"</item>
|
||||
<item quantity="other">"%1$d stupňů"</item>
|
||||
</plurals>
|
||||
<string name="screen_image_edition_title">"Upravit fotku"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Titulky nemusí být viditelné pro lidi, kteří používají starší aplikace."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Klepnutím změníte kvalitu nahrávání videa"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Soubor nelze nahrát."</string>
|
||||
@@ -17,9 +30,10 @@
|
||||
<string name="screen_media_upload_preview_item_count">"Položka %1$d z %2$d"</string>
|
||||
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimalizace kvality obrazu"</string>
|
||||
<string name="screen_media_upload_preview_processing">"Probíhá zpracování…"</string>
|
||||
<string name="screen_media_upload_preview_title">"Přidat média"</string>
|
||||
<string name="screen_report_content_block_user">"Zablokovat uživatele"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Zaškrtněte, pokud chcete skrýt všechny aktuální a budoucí zprávy od tohoto uživatele"</string>
|
||||
<string name="screen_report_content_explanation">"Tato zpráva bude nahlášena správci vašeho domovského serveru. Nebude si moci přečíst žádné šifrované zprávy."</string>
|
||||
<string name="screen_report_content_explanation">"Tato zpráva bude nahlášena vašemu poskytovateli účtu. Nebude moci číst žádné šifrované zprávy."</string>
|
||||
<string name="screen_report_content_hint">"Důvod nahlášení tohoto obsahu"</string>
|
||||
<string name="screen_room_attachment_source_camera">"Fotoaparát"</string>
|
||||
<string name="screen_room_attachment_source_camera_photo">"Vyfotit"</string>
|
||||
|
||||
@@ -47,6 +47,10 @@
|
||||
<string name="screen_room_attachment_source_location">"Standort teilen"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Umfrage"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Textformatierung"</string>
|
||||
<string name="screen_room_confirm_removal_reason_label">"Grund (optional)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Eingeben …"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(Optional)"</string>
|
||||
<string name="screen_room_confirm_removal_title">"Entfernen bestätigen"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"Der Nachrichtenverlauf ist derzeit nicht verfügbar."</string>
|
||||
<string name="screen_room_encrypted_history_banner_unverified">"Der Nachrichtenverlauf ist nicht verfügbar. Verifiziere dieses Gerät, um deinen Nachrichtenverlauf zu sehen."</string>
|
||||
<string name="screen_room_invite_again_alert_message">"Möchtest du sie wieder einladen?"</string>
|
||||
|
||||
@@ -7,6 +7,18 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Κρυπτογραφημένο από άγνωστη ή διαγεγραμμένη συσκευή."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Κρυπτογραφημένο από μια συσκευή που δεν έχει επαληθευτεί από τον ιδιοκτήτη της."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Κρυπτογραφημένο από μη επαληθευμένο χρήστη."</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Αναστροφή εικόνας οριζόντια"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Αναποδογυρισμένο οριζόντια"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Πρωτότυπο"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically">"Αναστροφή εικόνας κάθετα"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Αναποδογυρισμένο κάθετα"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Πρωτότυπο"</string>
|
||||
<string name="screen_image_edition_a11y_rotate_to_the_left">"Περιστρέψτε την εικόνα προς τα αριστερά"</string>
|
||||
<plurals name="screen_image_edition_a11y_rotation_state">
|
||||
<item quantity="one">"%1$d μοίρα"</item>
|
||||
<item quantity="other">"%1$d μοίρες"</item>
|
||||
</plurals>
|
||||
<string name="screen_image_edition_title">"Επεξεργασία φωτογραφίας"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Οι λεζάντες ενδέχεται να μην είναι ορατές σε άτομα που χρησιμοποιούν παλαιότερες εφαρμογές."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Πατήστε για να αλλάξετε την ποιότητα μεταφόρτωσης βίντεο"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Δεν ήταν δυνατή η μεταφόρτωση του αρχείου."</string>
|
||||
@@ -17,6 +29,7 @@
|
||||
<string name="screen_media_upload_preview_item_count">"Στοιχείο %1$d από %2$d"</string>
|
||||
<string name="screen_media_upload_preview_optimize_image_quality_title">"Βελτιστοποίηση ποιότητας εικόνας"</string>
|
||||
<string name="screen_media_upload_preview_processing">"Επεξεργασία…"</string>
|
||||
<string name="screen_media_upload_preview_title">"Προσθήκη πολυμέσων"</string>
|
||||
<string name="screen_report_content_block_user">"Αποκλεισμός χρήστη"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Επέλεξε εάν θες να αποκρύψεις όλα τα τρέχοντα και μελλοντικά μηνύματα από αυτόν τον χρήστη"</string>
|
||||
<string name="screen_report_content_explanation">"Αυτό το μήνυμα θα αναφερθεί στον πάροχο του λογαριασμού σας. Δεν θα μπορεί να διαβάσει κανένα κρυπτογραφημένο μήνυμα."</string>
|
||||
@@ -57,6 +70,10 @@
|
||||
<string name="screen_room_timeline_reactions_show_more">"Εμφάνιση περισσότερων"</string>
|
||||
<string name="screen_room_timeline_reactions_show_reactions_summary">"Εμφάνιση περίληψης αντιδράσεων"</string>
|
||||
<string name="screen_room_timeline_read_marker_title">"Νέο"</string>
|
||||
<plurals name="screen_room_timeline_redacted_messages">
|
||||
<item quantity="one">"%1$d μήνυμα αφαιρέθηκε"</item>
|
||||
<item quantity="other">"%1$d μηνύματα αφαιρέθηκαν"</item>
|
||||
</plurals>
|
||||
<plurals name="screen_room_timeline_state_changes">
|
||||
<item quantity="one">"%1$d αλλαγή αίθουσας"</item>
|
||||
<item quantity="other">"%1$d αλλαγές αίθουσας"</item>
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Tuntemattoman tai poistetun laitteen salaama."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Salattu laitteella, jota sen omistaja ei ole vahvistanut."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Vahvistamattoman käyttäjän salaama."</string>
|
||||
<string name="error_sending_failure_invalid_mime_type">"Virheellinen MIME-tyyppi: %s"</string>
|
||||
<string name="error_sending_failure_missing_media_content">"Mediasisältö puuttuu"</string>
|
||||
<string name="error_sending_failure_other">"Virheen tiedot: %s"</string>
|
||||
<string name="error_sending_failure_sending_from_unverified_device">"Lähetetään vahvistamattomasta laitteesta"</string>
|
||||
<string name="error_sending_failure_unknown">"Tuntematon virhe"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Käännä kuva vaakasuunnassa"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Käännetty vaakasuunnassa"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Alkuperäinen"</string>
|
||||
@@ -32,7 +37,7 @@
|
||||
<string name="screen_media_upload_preview_title">"Lisää mediaa"</string>
|
||||
<string name="screen_report_content_block_user">"Estä käyttäjä"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Valitse tämä, jos haluat piilottaa kaikki nykyiset ja tulevat viestit tältä käyttäjältä"</string>
|
||||
<string name="screen_report_content_explanation">"Tämä viesti ilmoitetaan kotipalvelimesi ylläpitäjälle. Ylläpitäjä ei pysty lukemaan salattuja viestejä."</string>
|
||||
<string name="screen_report_content_explanation">"Tämä viesti ilmoitetaan palveluntarjoajallesi. He eivät pysty lukemaan salattuja viestejä."</string>
|
||||
<string name="screen_report_content_hint">"Syy tämän sisällön ilmoittamiseen"</string>
|
||||
<string name="screen_room_attachment_source_camera">"Kamera"</string>
|
||||
<string name="screen_room_attachment_source_camera_photo">"Ota kuva"</string>
|
||||
@@ -42,6 +47,10 @@
|
||||
<string name="screen_room_attachment_source_location">"Jaa sijainti"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Kysely"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Tekstin muotoilu"</string>
|
||||
<string name="screen_room_confirm_removal_reason_label">"Syy (valinnainen)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Kirjoita…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(Valinnainen)"</string>
|
||||
<string name="screen_room_confirm_removal_title">"Vahvista poistaminen"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"Viestihistoria ei ole tällä hetkellä saatavilla"</string>
|
||||
<string name="screen_room_encrypted_history_banner_unverified">"Viestihistoria ei ole käytettävissä tässä huoneessa. Vahvista tämä laite nähdäksesi viestihistoriasi."</string>
|
||||
<string name="screen_room_invite_again_alert_message">"Haluatko kutsua heidät takaisin?"</string>
|
||||
|
||||
@@ -47,6 +47,10 @@
|
||||
<string name="screen_room_attachment_source_location">"Dijeli lokaciju"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Anketa"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Oblikovanje teksta"</string>
|
||||
<string name="screen_room_confirm_removal_reason_label">"Razlog (neobavezno)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Unesite…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(Neobavezno)"</string>
|
||||
<string name="screen_room_confirm_removal_title">"Potvrdite uklanjanje"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"Povijest poruka trenutačno nije dostupna."</string>
|
||||
<string name="screen_room_encrypted_history_banner_unverified">"Povijest poruka nije dostupna u ovoj sobi. Potvrdite ovaj uređaj kako biste vidjeli povijest poruka."</string>
|
||||
<string name="screen_room_invite_again_alert_message">"Želite li ih pozvati natrag?"</string>
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Ismeretlen vagy törölt eszköz által titkosítva."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"A tulajdonos által nem ellenőrzött eszköz által titkosítva."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Nem ellenőrzött felhasználó által titkosítva."</string>
|
||||
<string name="error_sending_failure_invalid_mime_type">"Érvénytelen MIME-típus: %s"</string>
|
||||
<string name="error_sending_failure_missing_media_content">"Hiányzó médiatartalom"</string>
|
||||
<string name="error_sending_failure_other">"Hiba részletei: %s"</string>
|
||||
<string name="error_sending_failure_sending_from_unverified_device">"Küldés ellenőrizetlen eszközről"</string>
|
||||
<string name="error_sending_failure_unknown">"Ismeretlen hiba"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Kép vízszintes tükrözése"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Vízszintesen tükrözve"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Eredeti"</string>
|
||||
@@ -42,6 +47,10 @@
|
||||
<string name="screen_room_attachment_source_location">"Hely megosztása"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Szavazás"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Szövegformázás"</string>
|
||||
<string name="screen_room_confirm_removal_reason_label">"Ok (nem kötelező)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Írja be…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(Választható)"</string>
|
||||
<string name="screen_room_confirm_removal_title">"Eltávolítás megerősítése"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"Az üzenetelőzmények jelenleg nem érhetők el."</string>
|
||||
<string name="screen_room_encrypted_history_banner_unverified">"Az üzenetelőzmények nem érhetők el ebben a szobában. Ellenőrizze ezt az eszközt, hogy lássa az előzményeket."</string>
|
||||
<string name="screen_room_invite_again_alert_message">"Visszahívja?"</string>
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"削除されたまたは不明な端末により暗号化されています。"</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"所有者に検証されていない端末により暗号化されています。"</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"未検証のユーザーにより暗号化されています。"</string>
|
||||
<string name="error_sending_failure_invalid_mime_type">"無効なMIMEタイプ: %s"</string>
|
||||
<string name="error_sending_failure_missing_media_content">"メディアの内容が見つかりません"</string>
|
||||
<string name="error_sending_failure_other">"エラーの詳細: %s"</string>
|
||||
<string name="error_sending_failure_sending_from_unverified_device">"未検証の端末から送信"</string>
|
||||
<string name="error_sending_failure_unknown">"不明なエラー"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"水平に反転"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"垂直に反転"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"元画像"</string>
|
||||
@@ -31,7 +36,7 @@
|
||||
<string name="screen_media_upload_preview_title">"メディアを追加"</string>
|
||||
<string name="screen_report_content_block_user">"ユーザーをブロック"</string>
|
||||
<string name="screen_report_content_block_user_hint">"このユーザーからのメッセージをすべて非表示にする場合はチェックしてください。"</string>
|
||||
<string name="screen_report_content_explanation">"このメッセージはホームサーバーの管理者に報告されます。暗号化されたメッセージを確認することはできません。"</string>
|
||||
<string name="screen_report_content_explanation">"このメッセージはアカウント提供元の管理者に報告されます。暗号化されたメッセージは確認することができません。"</string>
|
||||
<string name="screen_report_content_hint">"このコンテンツを通報する理由"</string>
|
||||
<string name="screen_room_attachment_source_camera">"カメラ"</string>
|
||||
<string name="screen_room_attachment_source_camera_photo">"写真を撮影"</string>
|
||||
@@ -41,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_reason_label">"理由 (任意)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"入力…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(任意)"</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>
|
||||
|
||||
@@ -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_reason_label">"Powód (opcjonalne)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Wprowadź powód…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(Opcjonalne)"</string>
|
||||
<string name="screen_room_confirm_removal_title">"Usunąć wiadomość?"</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_reason_label">"Причина (необязательно)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Введите…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(Необязательно)"</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>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<string name="screen_media_upload_preview_processing">"Bearbetar …"</string>
|
||||
<string name="screen_report_content_block_user">"Blockera användare"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Markera om du vill dölja alla nuvarande och framtida meddelanden från denna användare"</string>
|
||||
<string name="screen_report_content_explanation">"Det här meddelandet kommer att rapporteras till din hemservers administratör. Denne kommer inte att kunna läsa några krypterade meddelanden."</string>
|
||||
<string name="screen_report_content_explanation">"Det här meddelandet kommer att rapporteras till din kontoleverantör. Denne kommer inte att kunna läsa några krypterade meddelanden."</string>
|
||||
<string name="screen_report_content_hint">"Anledning till att rapportera detta innehåll"</string>
|
||||
<string name="screen_room_attachment_source_camera">"Kamera"</string>
|
||||
<string name="screen_room_attachment_source_camera_photo">"Ta ett foto"</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_reason_label">"Причина (необов\'язково)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Ввести причину…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(Необов\'язково)"</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>
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"由未知或已刪除的裝置加密。"</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"由未經其擁有者驗證的裝置加密。"</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"由未經驗證的使用者加密。"</string>
|
||||
<string name="error_sending_failure_invalid_mime_type">"無效的 MIME 類型:%s"</string>
|
||||
<string name="error_sending_failure_missing_media_content">"缺少媒體內容"</string>
|
||||
<string name="error_sending_failure_other">"錯誤詳細資訊:%s"</string>
|
||||
<string name="error_sending_failure_sending_from_unverified_device">"從未經驗證的裝置傳送"</string>
|
||||
<string name="error_sending_failure_unknown">"未知錯誤"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"水平翻轉圖片"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"水平翻轉"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"原始狀態"</string>
|
||||
@@ -31,7 +36,7 @@
|
||||
<string name="screen_media_upload_preview_title">"新增媒體"</string>
|
||||
<string name="screen_report_content_block_user">"封鎖使用者"</string>
|
||||
<string name="screen_report_content_block_user_hint">"檢查您是否要隱藏所有來自此使用者的目前及未來的訊息"</string>
|
||||
<string name="screen_report_content_explanation">"此訊息將會回報給您的家伺服器管理員。他們將無法讀取任何已加密的訊息。"</string>
|
||||
<string name="screen_report_content_explanation">"這則訊息將會通報給您的帳號服務提供者。他們將無法閱讀任何加密訊息。"</string>
|
||||
<string name="screen_report_content_hint">"檢舉這個內容的原因"</string>
|
||||
<string name="screen_room_attachment_source_camera">"照相機"</string>
|
||||
<string name="screen_room_attachment_source_camera_photo">"拍照"</string>
|
||||
@@ -41,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_reason_label">"理由(選擇性)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"輸入⋯⋯"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(選擇性)"</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_reason_label">"理由(可选)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"输入理由…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(可选)"</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>
|
||||
@@ -73,7 +77,7 @@
|
||||
<string name="screen_room_timeline_reactions_show_reactions_summary">"显示反应摘要"</string>
|
||||
<string name="screen_room_timeline_read_marker_title">"新消息"</string>
|
||||
<plurals name="screen_room_timeline_redacted_messages">
|
||||
<item quantity="other">"%1$d 个已移除的消息"</item>
|
||||
<item quantity="other">"%1$d 条已移除的消息"</item>
|
||||
</plurals>
|
||||
<plurals name="screen_room_timeline_state_changes">
|
||||
<item quantity="other">"%1$d 个房间变化"</item>
|
||||
|
||||
@@ -47,6 +47,10 @@
|
||||
<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_reason_label">"Reason (optional)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Enter reason…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(Optional)"</string>
|
||||
<string name="screen_room_confirm_removal_title">"Remove message?"</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>
|
||||
|
||||
+4
-3
@@ -13,13 +13,14 @@ 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.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
class FakeMessagesNavigator(
|
||||
private val onShowEventDebugInfoClickLambda: (eventId: EventId?, debugInfo: TimelineItemDebugInfo) -> Unit = { _, _ -> lambdaError() },
|
||||
private val onForwardEventClickLambda: (eventIds: List<EventId>) -> Unit = { _ -> lambdaError() },
|
||||
private val onForwardEventClickLambda: (eventIds: List<EventId>, timelineProvider: TimelineProvider) -> Unit = { _, _ -> lambdaError() },
|
||||
private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() },
|
||||
private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
|
||||
private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
|
||||
@@ -34,8 +35,8 @@ class FakeMessagesNavigator(
|
||||
onShowEventDebugInfoClickLambda(eventId, debugInfo)
|
||||
}
|
||||
|
||||
override fun forwardEvents(eventIds: List<EventId>) {
|
||||
onForwardEventClickLambda(eventIds)
|
||||
override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
|
||||
onForwardEventClickLambda(eventIds, timelineProvider)
|
||||
}
|
||||
|
||||
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
|
||||
|
||||
+91
-9
@@ -49,6 +49,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
|
||||
@@ -74,6 +75,7 @@ import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
|
||||
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
@@ -83,10 +85,12 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
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
|
||||
@@ -111,6 +115,7 @@ import io.element.android.tests.testutils.FakeLifecycleOwner
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.consumeItemsUntilTimeout
|
||||
import io.element.android.tests.testutils.lambda.any
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
@@ -131,7 +136,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()
|
||||
@@ -309,7 +313,7 @@ class MessagesPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - handle action forward navigates directly when multi select is disabled`() = runTest {
|
||||
val onForwardEventClickLambda = lambdaRecorder<List<EventId>, Unit> { }
|
||||
val onForwardEventClickLambda = lambdaRecorder<List<EventId>, TimelineProvider, Unit> { _, _ -> }
|
||||
val navigator = FakeMessagesNavigator(
|
||||
onForwardEventClickLambda = onForwardEventClickLambda,
|
||||
)
|
||||
@@ -321,7 +325,7 @@ class MessagesPresenterTest {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
onForwardEventClickLambda.assertions().isCalledOnce().with(value(listOf(AN_EVENT_ID)))
|
||||
onForwardEventClickLambda.assertions().isCalledOnce().with(value(listOf(AN_EVENT_ID)), any())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,13 +587,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 -> }
|
||||
@@ -1414,7 +1491,7 @@ class MessagesPresenterTest {
|
||||
assertThat(initialState.threads.hasThreads).isFalse()
|
||||
|
||||
// Enable the feature flag, now it should reflect the thread list state
|
||||
featureFlagService.setFeatureEnabled(FeatureFlags.RoomThreadList, true)
|
||||
featureFlagService.setFeatureEnabled(FeatureFlags.Threads, true)
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().threads.hasThreads).isTrue()
|
||||
|
||||
@@ -1452,7 +1529,7 @@ class MessagesPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - confirm selection action forwards the selection in timeline order`() = runTest {
|
||||
val onForwardEventClickLambda = lambdaRecorder<List<EventId>, Unit> { }
|
||||
val onForwardEventClickLambda = lambdaRecorder<List<EventId>, TimelineProvider, Unit> { _, _ -> }
|
||||
val navigator = FakeMessagesNavigator(onForwardEventClickLambda = onForwardEventClickLambda)
|
||||
val timelineEventSink = lambdaRecorder<TimelineEvent, Unit> { }
|
||||
val presenter = createMessagesPresenter(
|
||||
@@ -1474,7 +1551,7 @@ class MessagesPresenterTest {
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvent.ConfirmSelectionAction)
|
||||
onForwardEventClickLambda.assertions().isCalledOnce().with(value(listOf(AN_EVENT_ID, AN_EVENT_ID_2)))
|
||||
onForwardEventClickLambda.assertions().isCalledOnce().with(value(listOf(AN_EVENT_ID, AN_EVENT_ID_2)), any())
|
||||
timelineEventSink.assertions().isCalledOnce().with(value(TimelineEvent.ExitSelectionMode))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
@@ -1482,7 +1559,7 @@ class MessagesPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - confirm selection action does nothing when no selection is active`() = runTest {
|
||||
val onForwardEventClickLambda = lambdaRecorder<List<EventId>, Unit> { }
|
||||
val onForwardEventClickLambda = lambdaRecorder<List<EventId>, TimelineProvider, Unit> { _, _ -> }
|
||||
val navigator = FakeMessagesNavigator(onForwardEventClickLambda = onForwardEventClickLambda)
|
||||
val presenter = createMessagesPresenter(navigator = navigator)
|
||||
presenter.testWithLifecycleOwner {
|
||||
@@ -1554,7 +1631,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,
|
||||
|
||||
-1
@@ -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 {
|
||||
|
||||
-1
@@ -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()
|
||||
|
||||
-1
@@ -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()
|
||||
|
||||
+4
@@ -32,6 +32,8 @@ import io.element.android.features.poll.test.pollcontent.FakePollContentStateFac
|
||||
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.htmlrenderer.test.FakeHtmlMessageParser
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
@@ -59,6 +61,8 @@ internal fun aTimelineItemContentFactory(
|
||||
htmlConverterProvider = FakeHtmlConverterProvider(),
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
textPillificationHelper = FakeTextPillificationHelper(),
|
||||
htmlMessageParser = FakeHtmlMessageParser(),
|
||||
featureFlagService = FakeFeatureFlagService(),
|
||||
),
|
||||
redactedMessageFactory = TimelineItemContentRedactedFactory(),
|
||||
stickerFactory = TimelineItemContentStickerFactory(
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
+7
-2
@@ -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,
|
||||
|
||||
+4
-3
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.pinned.list
|
||||
|
||||
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.timeline.TimelineProvider
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
|
||||
class FakePinnedMessagesListNavigator : PinnedMessagesListNavigator {
|
||||
@@ -23,9 +24,9 @@ class FakePinnedMessagesListNavigator : PinnedMessagesListNavigator {
|
||||
onShowEventDebugInfoClickLambda?.invoke(eventId, debugInfo)
|
||||
}
|
||||
|
||||
var onForwardEventClickLambda: ((EventId) -> Unit)? = null
|
||||
override fun forwardEvent(eventId: EventId) {
|
||||
onForwardEventClickLambda?.invoke(eventId)
|
||||
var onForwardEventClickLambda: ((EventId, TimelineProvider) -> Unit)? = null
|
||||
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) {
|
||||
onForwardEventClickLambda?.invoke(eventId, timelineProvider)
|
||||
}
|
||||
|
||||
var onOpenThreadLambda: ((ThreadId) -> Unit)? = null
|
||||
|
||||
+4
-2
@@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
|
||||
@@ -47,6 +48,7 @@ import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.lambda.any
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
@@ -329,7 +331,7 @@ class PinnedMessagesListPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - forward event`() = runTest {
|
||||
val onForwardEventClickLambda = lambdaRecorder { _: EventId -> }
|
||||
val onForwardEventClickLambda = lambdaRecorder { _: EventId, _: TimelineProvider -> }
|
||||
val navigator = FakePinnedMessagesListNavigator().apply {
|
||||
this.onForwardEventClickLambda = onForwardEventClickLambda
|
||||
}
|
||||
@@ -352,7 +354,7 @@ class PinnedMessagesListPresenterTest {
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
assert(onForwardEventClickLambda)
|
||||
.isCalledOnce()
|
||||
.with(value(AN_EVENT_ID))
|
||||
.with(value(AN_EVENT_ID), any())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+34
-9
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+11
-3
@@ -107,7 +107,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
|
||||
@@ -1083,7 +1082,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()
|
||||
}
|
||||
@@ -2010,7 +2013,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() },
|
||||
|
||||
+43
@@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
@@ -37,6 +38,10 @@ import io.element.android.features.messages.impl.utils.FakeTextPillificationHelp
|
||||
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
|
||||
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.htmlrenderer.api.DocumentNode
|
||||
import io.element.android.libraries.htmlrenderer.test.FakeHtmlMessageParser
|
||||
import io.element.android.libraries.matrix.api.media.AudioDetails
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
@@ -80,6 +85,7 @@ import kotlinx.coroutines.test.runTest
|
||||
import org.jsoup.nodes.Document
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import kotlin.jvm.java
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
@@ -1216,6 +1222,40 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
|
||||
assertThat(galleryContent.formattedCaption).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test create will only use the new parser if the feature flag is enabled`() = runTest {
|
||||
val featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(
|
||||
FeatureFlags.NewTimelineEventRenderer.key to true
|
||||
)
|
||||
)
|
||||
val sut = createTimelineItemContentMessageFactory(
|
||||
featureFlagService = featureFlagService
|
||||
)
|
||||
val resultWithFeatureFlag = sut.create(
|
||||
content = createMessageContent(type = TextMessageType("body", FormattedBody(MessageFormat.HTML, "html body"))),
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
|
||||
// The message tree is present when the feature flag is enabled
|
||||
assertThat(resultWithFeatureFlag).isInstanceOf(TimelineItemTextBasedContent::class.java)
|
||||
assertThat((resultWithFeatureFlag as TimelineItemTextBasedContent).messageTree).isNotNull()
|
||||
|
||||
featureFlagService.setFeatureEnabled(FeatureFlags.NewTimelineEventRenderer, false)
|
||||
val resultWithoutFeatureFlag = sut.create(
|
||||
content = createMessageContent(type = TextMessageType("body", FormattedBody(MessageFormat.HTML, "html body"))),
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
|
||||
// The message tree is null when the feature flag is disabled
|
||||
assertThat(resultWithoutFeatureFlag).isInstanceOf(TimelineItemTextBasedContent::class.java)
|
||||
assertThat((resultWithoutFeatureFlag as TimelineItemTextBasedContent).messageTree).isNull()
|
||||
}
|
||||
|
||||
private fun createMessageContent(
|
||||
body: String = "Body",
|
||||
inReplyTo: InReplyTo? = null,
|
||||
@@ -1236,12 +1276,15 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
|
||||
htmlConverterTransform: (String) -> CharSequence = { it },
|
||||
domConverterTransform: (Document) -> CharSequence = { it.body().html() },
|
||||
permalinkParser: FakePermalinkParser = FakePermalinkParser(),
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||
) = TimelineItemContentMessageFactory(
|
||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||
htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform, domConverterTransform),
|
||||
permalinkParser = permalinkParser,
|
||||
textPillificationHelper = FakeTextPillificationHelper(),
|
||||
htmlMessageParser = FakeHtmlMessageParser(parseResult = { DocumentNode(persistentListOf()) }),
|
||||
featureFlagService = featureFlagService,
|
||||
)
|
||||
|
||||
private fun createStickerContent(
|
||||
|
||||
-1
@@ -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()
|
||||
|
||||
+23
-5
@@ -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() =
|
||||
|
||||
Reference in New Issue
Block a user