First Commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2022-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.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.messages.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.mediaviewer.api)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
api(projects.libraries.textcomposer.impl)
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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.api
|
||||
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
|
||||
/**
|
||||
* Hoist-able state of the message composer.
|
||||
*
|
||||
* Typical use case is inside other presenters, to know if
|
||||
* the composer is in a thread, if it's editing a message, etc.
|
||||
*/
|
||||
interface MessageComposerContext {
|
||||
val composerMode: MessageComposerMode
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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.api
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
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.permalink.PermalinkData
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
interface MessagesEntryPoint : FeatureEntryPoint {
|
||||
sealed interface InitialTarget : Parcelable {
|
||||
@Parcelize
|
||||
data class Messages(
|
||||
val focusedEventId: EventId?,
|
||||
) : InitialTarget
|
||||
|
||||
@Parcelize
|
||||
data object PinnedMessages : InitialTarget
|
||||
}
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun navigateToRoomDetails()
|
||||
fun navigateToRoomMemberDetails(userId: UserId)
|
||||
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
|
||||
fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean)
|
||||
fun navigateToRoom(roomId: RoomId)
|
||||
}
|
||||
|
||||
data class Params(val initialTarget: InitialTarget) : NodeInputs
|
||||
|
||||
fun createNode(
|
||||
parentNode: Node,
|
||||
buildContext: BuildContext,
|
||||
params: Params,
|
||||
callback: Callback,
|
||||
): Node
|
||||
|
||||
interface NodeProxy {
|
||||
suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?)
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.api.pinned
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
|
||||
interface PinnedEventsTimelineProvider : TimelineProvider
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* 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.api.timeline
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.element.android.wysiwyg.utils.HtmlConverter
|
||||
|
||||
interface HtmlConverterProvider {
|
||||
@Composable
|
||||
fun Update()
|
||||
|
||||
fun provide(): HtmlConverter
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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.api.timeline.voicemessages.composer
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
||||
|
||||
sealed interface VoiceMessageComposerEvent {
|
||||
data class RecorderEvent(
|
||||
val recorderEvent: VoiceMessageRecorderEvent
|
||||
) : VoiceMessageComposerEvent
|
||||
data class PlayerEvent(
|
||||
val playerEvent: VoiceMessagePlayerEvent,
|
||||
) : VoiceMessageComposerEvent
|
||||
data object SendVoiceMessage : VoiceMessageComposerEvent
|
||||
data object DeleteVoiceMessage : VoiceMessageComposerEvent
|
||||
data object AcceptPermissionRationale : VoiceMessageComposerEvent
|
||||
data object DismissPermissionsRationale : VoiceMessageComposerEvent
|
||||
data class LifecycleEvent(val event: Lifecycle.Event) : VoiceMessageComposerEvent
|
||||
data object DismissSendFailureDialog : VoiceMessageComposerEvent
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.api.timeline.voicemessages.composer
|
||||
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
|
||||
fun interface VoiceMessageComposerPresenter : Presenter<VoiceMessageComposerState> {
|
||||
interface Factory {
|
||||
fun create(timelineMode: Timeline.Mode): VoiceMessageComposerPresenter
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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.api.timeline.voicemessages.composer
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
|
||||
@Stable
|
||||
data class VoiceMessageComposerState(
|
||||
val voiceMessageState: VoiceMessageState,
|
||||
val showPermissionRationaleDialog: Boolean,
|
||||
val showSendFailureDialog: Boolean,
|
||||
val keepScreenOn: Boolean,
|
||||
val eventSink: (VoiceMessageComposerEvent) -> Unit,
|
||||
)
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.api.timeline.voicemessages.composer
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.designsystem.components.media.WaveFormSamples
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> {
|
||||
override val values: Sequence<VoiceMessageComposerState>
|
||||
get() = sequenceOf(
|
||||
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(duration = 61.seconds, levels = WaveFormSamples.allRangeWaveForm)),
|
||||
)
|
||||
}
|
||||
|
||||
fun aVoiceMessageComposerState(
|
||||
voiceMessageState: VoiceMessageState = VoiceMessageState.Idle,
|
||||
keepScreenOn: Boolean = false,
|
||||
showPermissionRationaleDialog: Boolean = false,
|
||||
showSendFailureDialog: Boolean = false,
|
||||
) = VoiceMessageComposerState(
|
||||
voiceMessageState = voiceMessageState,
|
||||
showPermissionRationaleDialog = showPermissionRationaleDialog,
|
||||
showSendFailureDialog = showSendFailureDialog,
|
||||
keepScreenOn = keepScreenOn,
|
||||
eventSink = {},
|
||||
)
|
||||
|
||||
fun aVoiceMessagePreviewState() = VoiceMessageState.Preview(
|
||||
isSending = false,
|
||||
isPlaying = false,
|
||||
showCursor = false,
|
||||
playbackProgress = 0f,
|
||||
time = 10.seconds,
|
||||
waveform = WaveFormSamples.realisticWaveForm,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,105 @@
|
||||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2022-2024 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.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.messages.impl"
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupDependencyInjection()
|
||||
|
||||
dependencies {
|
||||
api(projects.features.messages.api)
|
||||
implementation(projects.appconfig)
|
||||
implementation(projects.features.call.api)
|
||||
implementation(projects.features.enterprise.api)
|
||||
implementation(projects.features.forward.api)
|
||||
implementation(projects.features.location.api)
|
||||
implementation(projects.features.poll.api)
|
||||
implementation(projects.features.roomcall.api)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.matrixmedia.api)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.textcomposer.impl)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.dateformatter.api)
|
||||
implementation(projects.libraries.eventformatter.api)
|
||||
implementation(projects.libraries.mediapickers.api)
|
||||
implementation(projects.libraries.mediaviewer.api)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.mediaupload.api)
|
||||
implementation(projects.libraries.permissions.api)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.recentemojis.api)
|
||||
implementation(projects.libraries.roomselect.api)
|
||||
implementation(projects.libraries.voiceplayer.api)
|
||||
implementation(projects.libraries.voicerecorder.api)
|
||||
implementation(projects.libraries.mediaplayer.api)
|
||||
implementation(projects.libraries.push.api)
|
||||
implementation(projects.libraries.uiUtils)
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(projects.features.networkmonitor.api)
|
||||
implementation(projects.services.analytics.compose)
|
||||
implementation(projects.services.appnavstate.api)
|
||||
implementation(projects.services.toolbox.api)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.datetime)
|
||||
implementation(libs.jsoup)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.androidx.constraintlayout.compose)
|
||||
implementation(libs.androidx.media3.exoplayer)
|
||||
implementation(libs.androidx.media3.ui)
|
||||
implementation(libs.sigpwned.emoji4j)
|
||||
implementation(libs.vanniktech.blurhash)
|
||||
implementation(libs.telephoto.zoomableimage)
|
||||
implementation(libs.matrix.emojibase.bindings)
|
||||
implementation(projects.features.knockrequests.api)
|
||||
implementation(projects.features.roommembermoderation.api)
|
||||
|
||||
testCommonDependencies(libs, true)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.dateformatter.test)
|
||||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.features.call.test)
|
||||
testImplementation(projects.features.forward.test)
|
||||
testImplementation(projects.features.knockrequests.test)
|
||||
testImplementation(projects.features.location.test)
|
||||
testImplementation(projects.features.networkmonitor.test)
|
||||
testImplementation(projects.features.messages.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.services.toolbox.test)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
testImplementation(projects.libraries.mediaupload.impl)
|
||||
testImplementation(projects.libraries.mediaupload.test)
|
||||
testImplementation(projects.libraries.mediapickers.test)
|
||||
testImplementation(projects.libraries.permissions.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.libraries.voicerecorder.test)
|
||||
testImplementation(projects.libraries.mediaplayer.test)
|
||||
testImplementation(projects.libraries.mediaviewer.test)
|
||||
testImplementation(projects.libraries.testtags)
|
||||
testImplementation(projects.features.poll.test)
|
||||
testImplementation(projects.libraries.eventformatter.test)
|
||||
testImplementation(projects.libraries.recentemojis.test)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright (c) 2025 Element Creations Ltd.
|
||||
~ Copyright 2023 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.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
</manifest>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.messages.api.MessagesEntryPoint
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultMessagesEntryPoint : MessagesEntryPoint {
|
||||
override fun createNode(
|
||||
parentNode: Node,
|
||||
buildContext: BuildContext,
|
||||
params: MessagesEntryPoint.Params,
|
||||
callback: MessagesEntryPoint.Callback,
|
||||
): Node {
|
||||
return parentNode.createNode<MessagesFlowNode>(buildContext, listOf(params, callback))
|
||||
}
|
||||
}
|
||||
|
||||
internal fun MessagesEntryPoint.InitialTarget.toNavTarget() = when (this) {
|
||||
is MessagesEntryPoint.InitialTarget.Messages -> MessagesFlowNode.NavTarget.Messages(focusedEventId)
|
||||
MessagesEntryPoint.InitialTarget.PinnedMessages -> MessagesFlowNode.NavTarget.PinnedMessagesList
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
sealed interface MessagesEvents {
|
||||
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents
|
||||
data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvents
|
||||
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvents
|
||||
data class OnUserClicked(val user: MatrixUser) : MessagesEvents
|
||||
data object Dismiss : MessagesEvents
|
||||
data object MarkAsFullyReadAndExit : MessagesEvents
|
||||
}
|
||||
|
||||
enum class InviteDialogAction {
|
||||
Cancel,
|
||||
Invite,
|
||||
}
|
||||
+634
@@ -0,0 +1,634 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.pop
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.Interaction
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.api.ElementCallEntryPoint
|
||||
import io.element.android.features.forward.api.ForwardEntryPoint
|
||||
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.features.location.api.LocationService
|
||||
import io.element.android.features.location.api.SendLocationEntryPoint
|
||||
import io.element.android.features.location.api.ShowLocationEntryPoint
|
||||
import io.element.android.features.messages.api.MessagesEntryPoint
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
|
||||
import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider
|
||||
import io.element.android.features.messages.impl.pinned.list.PinnedMessagesListNode
|
||||
import io.element.android.features.messages.impl.report.ReportMessageNode
|
||||
import io.element.android.features.messages.impl.threads.ThreadedMessagesNode
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.duration
|
||||
import io.element.android.features.poll.api.create.CreatePollEntryPoint
|
||||
import io.element.android.features.poll.api.create.CreatePollMode
|
||||
import io.element.android.libraries.architecture.BackstackWithOverlayBox
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.callback
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.overlay.Overlay
|
||||
import io.element.android.libraries.architecture.overlay.operation.hide
|
||||
import io.element.android.libraries.architecture.overlay.operation.show
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
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.SessionId
|
||||
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.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||
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.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
|
||||
import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanUpdater
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
@AssistedInject
|
||||
class MessagesFlowNode(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val roomListService: RoomListService,
|
||||
private val sessionId: SessionId,
|
||||
private val sendLocationEntryPoint: SendLocationEntryPoint,
|
||||
private val showLocationEntryPoint: ShowLocationEntryPoint,
|
||||
private val createPollEntryPoint: CreatePollEntryPoint,
|
||||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
||||
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
|
||||
private val forwardEntryPoint: ForwardEntryPoint,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val locationService: LocationService,
|
||||
private val room: BaseRoom,
|
||||
private val roomMemberProfilesCache: RoomMemberProfilesCache,
|
||||
private val roomNamesCache: RoomNamesCache,
|
||||
private val mentionSpanUpdater: MentionSpanUpdater,
|
||||
private val mentionSpanTheme: MentionSpanTheme,
|
||||
private val pinnedEventsTimelineProvider: DefaultPinnedEventsTimelineProvider,
|
||||
private val timelineController: TimelineController,
|
||||
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
|
||||
private val dateFormatter: DateFormatter,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = plugins.filterIsInstance<MessagesEntryPoint.Params>().first().initialTarget.toNavTarget(),
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
overlay = Overlay(
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
), MessagesEntryPoint.NodeProxy {
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data class Messages(val focusedEventId: EventId?) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class MediaViewer(
|
||||
val mode: MediaViewerEntryPoint.MediaViewerMode,
|
||||
val eventId: EventId?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment, val inReplyToEventId: EventId?) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class LocationViewer(val location: Location, val description: String?) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class ForwardEvent(
|
||||
val eventId: EventId,
|
||||
val fromPinnedEvents: Boolean,
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class SendLocation(val timelineMode: Timeline.Mode) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class CreatePoll(val timelineMode: Timeline.Mode) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class EditPoll(val timelineMode: Timeline.Mode, val eventId: EventId) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object PinnedMessagesList : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object KnockRequestsList : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget
|
||||
}
|
||||
|
||||
private val callback: MessagesEntryPoint.Callback = callback()
|
||||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
lifecycle.subscribe(
|
||||
onDestroy = {
|
||||
timelineController.close()
|
||||
}
|
||||
)
|
||||
setupCacheUpdaters()
|
||||
|
||||
pinnedEventsTimelineProvider.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
private fun setupCacheUpdaters() {
|
||||
room.membersStateFlow
|
||||
.onEach { membersState ->
|
||||
withContext(coroutineDispatchers.computation) {
|
||||
roomMemberProfilesCache.replace(membersState.joinedRoomMembers())
|
||||
}
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
roomListService
|
||||
.allRooms
|
||||
.summaries
|
||||
.onEach {
|
||||
withContext(coroutineDispatchers.computation) {
|
||||
roomNamesCache.replace(it)
|
||||
}
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
is NavTarget.Messages -> {
|
||||
val callback = object : MessagesNode.Callback {
|
||||
override fun navigateToRoomDetails() {
|
||||
callback.navigateToRoomDetails()
|
||||
}
|
||||
|
||||
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
|
||||
return processEventClick(
|
||||
timelineMode = timelineMode,
|
||||
event = event,
|
||||
)
|
||||
}
|
||||
|
||||
override fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) {
|
||||
backstack.push(
|
||||
NavTarget.AttachmentPreview(
|
||||
attachment = attachments.first(),
|
||||
timelineMode = Timeline.Mode.Live,
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun navigateToRoomMemberDetails(userId: UserId) {
|
||||
callback.navigateToRoomMemberDetails(userId)
|
||||
}
|
||||
|
||||
override fun handlePermalinkClick(data: PermalinkData) {
|
||||
callback.handlePermalinkClick(data, pushToBackstack = true)
|
||||
}
|
||||
|
||||
override fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
|
||||
}
|
||||
|
||||
override fun forwardEvent(eventId: EventId) {
|
||||
backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents = false))
|
||||
}
|
||||
|
||||
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
|
||||
backstack.push(NavTarget.ReportMessage(eventId, senderId))
|
||||
}
|
||||
|
||||
override fun navigateToSendLocation() {
|
||||
backstack.push(NavTarget.SendLocation(Timeline.Mode.Live))
|
||||
}
|
||||
|
||||
override fun navigateToCreatePoll() {
|
||||
backstack.push(NavTarget.CreatePoll(Timeline.Mode.Live))
|
||||
}
|
||||
|
||||
override fun navigateToEditPoll(eventId: EventId) {
|
||||
backstack.push(NavTarget.EditPoll(Timeline.Mode.Live, eventId))
|
||||
}
|
||||
|
||||
override fun navigateToRoomCall(roomId: RoomId) {
|
||||
val callType = CallType.RoomCall(
|
||||
sessionId = sessionId,
|
||||
roomId = roomId,
|
||||
)
|
||||
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
|
||||
elementCallEntryPoint.startCall(callType)
|
||||
}
|
||||
|
||||
override fun navigateToPinnedMessagesList() {
|
||||
backstack.push(NavTarget.PinnedMessagesList)
|
||||
}
|
||||
|
||||
override fun navigateToKnockRequestsList() {
|
||||
backstack.push(NavTarget.KnockRequestsList)
|
||||
}
|
||||
|
||||
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
||||
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
|
||||
}
|
||||
}
|
||||
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
|
||||
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
|
||||
}
|
||||
is NavTarget.MediaViewer -> {
|
||||
val params = MediaViewerEntryPoint.Params(
|
||||
mode = navTarget.mode,
|
||||
eventId = navTarget.eventId,
|
||||
mediaInfo = navTarget.mediaInfo,
|
||||
mediaSource = navTarget.mediaSource,
|
||||
thumbnailSource = navTarget.thumbnailSource,
|
||||
canShowInfo = true,
|
||||
)
|
||||
val callback = object : MediaViewerEntryPoint.Callback {
|
||||
override fun onDone() {
|
||||
overlay.hide()
|
||||
}
|
||||
|
||||
override fun viewInTimeline(eventId: EventId) {
|
||||
this@MessagesFlowNode.viewInTimeline(eventId)
|
||||
}
|
||||
|
||||
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) {
|
||||
// Need to go to the parent because of the overlay
|
||||
callback.forwardEvent(eventId, fromPinnedEvents)
|
||||
}
|
||||
}
|
||||
mediaViewerEntryPoint.createNode(
|
||||
parentNode = this,
|
||||
buildContext = buildContext,
|
||||
params = params,
|
||||
callback = callback
|
||||
)
|
||||
}
|
||||
is NavTarget.AttachmentPreview -> {
|
||||
val inputs = AttachmentsPreviewNode.Inputs(
|
||||
attachment = navTarget.attachment,
|
||||
timelineMode = navTarget.timelineMode,
|
||||
inReplyToEventId = navTarget.inReplyToEventId,
|
||||
)
|
||||
createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs))
|
||||
}
|
||||
is NavTarget.LocationViewer -> {
|
||||
val inputs = ShowLocationEntryPoint.Inputs(navTarget.location, navTarget.description)
|
||||
showLocationEntryPoint.createNode(
|
||||
parentNode = this,
|
||||
buildContext = buildContext,
|
||||
inputs = inputs,
|
||||
)
|
||||
}
|
||||
is NavTarget.EventDebugInfo -> {
|
||||
val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo)
|
||||
createNode<EventDebugInfoNode>(buildContext, listOf(inputs))
|
||||
}
|
||||
is NavTarget.ForwardEvent -> {
|
||||
val timelineProvider = if (navTarget.fromPinnedEvents) {
|
||||
pinnedEventsTimelineProvider
|
||||
} else {
|
||||
timelineController
|
||||
}
|
||||
val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider)
|
||||
val callback = object : ForwardEntryPoint.Callback {
|
||||
override fun onDone(roomIds: List<RoomId>) {
|
||||
backstack.pop()
|
||||
roomIds.singleOrNull()?.let { roomId ->
|
||||
callback.navigateToRoom(roomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
forwardEntryPoint.createNode(
|
||||
parentNode = this,
|
||||
buildContext = buildContext,
|
||||
params = params,
|
||||
callback = callback,
|
||||
)
|
||||
}
|
||||
is NavTarget.ReportMessage -> {
|
||||
val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId)
|
||||
createNode<ReportMessageNode>(buildContext, listOf(inputs))
|
||||
}
|
||||
is NavTarget.SendLocation -> {
|
||||
sendLocationEntryPoint.createNode(
|
||||
parentNode = this,
|
||||
buildContext = buildContext,
|
||||
timelineMode = navTarget.timelineMode,
|
||||
)
|
||||
}
|
||||
is NavTarget.CreatePoll -> {
|
||||
createPollEntryPoint.createNode(
|
||||
parentNode = this,
|
||||
buildContext = buildContext,
|
||||
params = CreatePollEntryPoint.Params(
|
||||
timelineMode = navTarget.timelineMode,
|
||||
mode = CreatePollMode.NewPoll
|
||||
),
|
||||
)
|
||||
}
|
||||
is NavTarget.EditPoll -> {
|
||||
createPollEntryPoint.createNode(
|
||||
parentNode = this,
|
||||
buildContext = buildContext,
|
||||
params = CreatePollEntryPoint.Params(
|
||||
timelineMode = navTarget.timelineMode,
|
||||
mode = CreatePollMode.EditPoll(eventId = navTarget.eventId)
|
||||
),
|
||||
)
|
||||
}
|
||||
NavTarget.PinnedMessagesList -> {
|
||||
val callback = object : PinnedMessagesListNode.Callback {
|
||||
override fun handleEventClick(event: TimelineItem.Event) {
|
||||
processEventClick(
|
||||
timelineMode = Timeline.Mode.PinnedEvents,
|
||||
event = event,
|
||||
)
|
||||
}
|
||||
|
||||
override fun navigateToRoomMemberDetails(userId: UserId) {
|
||||
callback.navigateToRoomMemberDetails(userId)
|
||||
}
|
||||
|
||||
override fun viewInTimeline(eventId: EventId) {
|
||||
this@MessagesFlowNode.viewInTimeline(eventId)
|
||||
}
|
||||
|
||||
override fun handlePermalinkClick(data: PermalinkData.RoomLink) {
|
||||
callback.handlePermalinkClick(data, pushToBackstack = !room.matches(data.roomIdOrAlias))
|
||||
}
|
||||
|
||||
override fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
|
||||
}
|
||||
|
||||
override fun handleForwardEventClick(eventId: EventId) {
|
||||
backstack.push(NavTarget.ForwardEvent(eventId = eventId, fromPinnedEvents = true))
|
||||
}
|
||||
}
|
||||
createNode<PinnedMessagesListNode>(buildContext, plugins = listOf(callback))
|
||||
}
|
||||
NavTarget.KnockRequestsList -> {
|
||||
knockRequestsListEntryPoint.createNode(this, buildContext)
|
||||
}
|
||||
is NavTarget.Thread -> {
|
||||
val inputs = ThreadedMessagesNode.Inputs(
|
||||
threadRootEventId = navTarget.threadRootId,
|
||||
focusedEventId = navTarget.focusedEventId,
|
||||
)
|
||||
val callback = object : ThreadedMessagesNode.Callback {
|
||||
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
|
||||
return processEventClick(
|
||||
timelineMode = timelineMode,
|
||||
event = event,
|
||||
)
|
||||
}
|
||||
|
||||
override fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) {
|
||||
backstack.push(
|
||||
NavTarget.AttachmentPreview(
|
||||
attachment = attachments.first(),
|
||||
timelineMode = Timeline.Mode.Thread(navTarget.threadRootId),
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun navigateToRoomMemberDetails(userId: UserId) {
|
||||
callback.navigateToRoomMemberDetails(userId)
|
||||
}
|
||||
|
||||
override fun handlePermalinkClick(data: PermalinkData) {
|
||||
callback.handlePermalinkClick(data, pushToBackstack = true)
|
||||
}
|
||||
|
||||
override fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
|
||||
}
|
||||
|
||||
override fun handleForwardEventClick(eventId: EventId) {
|
||||
backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents = false))
|
||||
}
|
||||
|
||||
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
|
||||
backstack.push(NavTarget.ReportMessage(eventId, senderId))
|
||||
}
|
||||
|
||||
override fun navigateToSendLocation() {
|
||||
backstack.push(NavTarget.SendLocation(Timeline.Mode.Thread(navTarget.threadRootId)))
|
||||
}
|
||||
|
||||
override fun navigateToCreatePoll() {
|
||||
backstack.push(NavTarget.CreatePoll(Timeline.Mode.Thread(navTarget.threadRootId)))
|
||||
}
|
||||
|
||||
override fun navigateToEditPoll(eventId: EventId) {
|
||||
backstack.push(NavTarget.EditPoll(Timeline.Mode.Thread(navTarget.threadRootId), eventId))
|
||||
}
|
||||
|
||||
override fun navigateToRoomCall(roomId: RoomId) {
|
||||
val callType = CallType.RoomCall(
|
||||
sessionId = sessionId,
|
||||
roomId = roomId,
|
||||
)
|
||||
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
|
||||
elementCallEntryPoint.startCall(callType)
|
||||
}
|
||||
|
||||
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
||||
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
|
||||
}
|
||||
}
|
||||
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun viewInTimeline(eventId: EventId) {
|
||||
val permalinkData = PermalinkData.RoomLink(
|
||||
roomIdOrAlias = room.roomId.toRoomIdOrAlias(),
|
||||
eventId = eventId,
|
||||
)
|
||||
callback.handlePermalinkClick(permalinkData, pushToBackstack = false)
|
||||
}
|
||||
|
||||
private fun processEventClick(
|
||||
timelineMode: Timeline.Mode,
|
||||
event: TimelineItem.Event,
|
||||
): Boolean {
|
||||
val navTarget = when (event.content) {
|
||||
is TimelineItemImageContent -> {
|
||||
buildMediaViewerNavTarget(
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode),
|
||||
event = event,
|
||||
content = event.content,
|
||||
mediaSource = event.content.mediaSource,
|
||||
thumbnailSource = event.content.thumbnailSource,
|
||||
)
|
||||
}
|
||||
is TimelineItemVideoContent -> {
|
||||
buildMediaViewerNavTarget(
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode),
|
||||
event = event,
|
||||
content = event.content,
|
||||
mediaSource = event.content.mediaSource,
|
||||
thumbnailSource = event.content.thumbnailSource,
|
||||
)
|
||||
}
|
||||
is TimelineItemFileContent -> {
|
||||
buildMediaViewerNavTarget(
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode),
|
||||
event = event,
|
||||
content = event.content,
|
||||
mediaSource = event.content.mediaSource,
|
||||
thumbnailSource = event.content.thumbnailSource,
|
||||
)
|
||||
}
|
||||
is TimelineItemAudioContent -> {
|
||||
buildMediaViewerNavTarget(
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode),
|
||||
event = event,
|
||||
content = event.content,
|
||||
mediaSource = event.content.mediaSource,
|
||||
thumbnailSource = null,
|
||||
)
|
||||
}
|
||||
is TimelineItemLocationContent -> {
|
||||
NavTarget.LocationViewer(
|
||||
location = event.content.location,
|
||||
description = event.content.description,
|
||||
).takeIf { locationService.isServiceAvailable() }
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
return when (navTarget) {
|
||||
is NavTarget.MediaViewer -> {
|
||||
overlay.show(navTarget)
|
||||
true
|
||||
}
|
||||
is NavTarget.LocationViewer -> {
|
||||
backstack.push(navTarget)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildMediaViewerNavTarget(
|
||||
mode: MediaViewerEntryPoint.MediaViewerMode,
|
||||
event: TimelineItem.Event,
|
||||
content: TimelineItemEventContentWithAttachment,
|
||||
mediaSource: MediaSource,
|
||||
thumbnailSource: MediaSource?,
|
||||
): NavTarget {
|
||||
return NavTarget.MediaViewer(
|
||||
mode = mode,
|
||||
eventId = event.eventId,
|
||||
mediaInfo = MediaInfo(
|
||||
filename = content.filename,
|
||||
fileSize = content.fileSize,
|
||||
caption = content.caption,
|
||||
mimeType = content.mimeType,
|
||||
formattedFileSize = content.formattedFileSize,
|
||||
fileExtension = content.fileExtension,
|
||||
senderId = event.senderId,
|
||||
senderName = event.safeSenderName,
|
||||
senderAvatar = event.senderAvatar.url,
|
||||
dateSent = dateFormatter.format(
|
||||
event.sentTimeMillis,
|
||||
mode = DateFormatterMode.Day,
|
||||
),
|
||||
dateSentFull = dateFormatter.format(
|
||||
timestamp = event.sentTimeMillis,
|
||||
mode = DateFormatterMode.Full,
|
||||
),
|
||||
waveform = (content as? TimelineItemVoiceContent)?.waveform,
|
||||
duration = content.duration()?.toHumanReadableDuration(),
|
||||
),
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = thumbnailSource,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) {
|
||||
// Wait until we have the UI for the main timeline attached
|
||||
waitForChildAttached<MessagesNode>()
|
||||
// Give some time for the items in the main timeline to be received, otherwise loading the focused thread root id won't work
|
||||
// (look at TimelineItemIndexer and firstProcessLatch for more info)
|
||||
delay(10.milliseconds)
|
||||
// Then push the new threads screen on top
|
||||
backstack.push(NavTarget.Thread(threadId, focusedEventId))
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
mentionSpanTheme.updateStyles()
|
||||
CompositionLocalProvider(
|
||||
LocalMentionSpanUpdater provides mentionSpanUpdater
|
||||
) {
|
||||
BackstackWithOverlayBox(modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
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.item.TimelineItemDebugInfo
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
interface MessagesNavigator {
|
||||
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||
fun forwardEvent(eventId: EventId)
|
||||
fun navigateToReportMessage(eventId: EventId, senderId: UserId)
|
||||
fun navigateToEditPoll(eventId: EventId)
|
||||
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
|
||||
fun navigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List<String>)
|
||||
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
||||
fun close()
|
||||
}
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.knockrequests.api.banner.KnockRequestsBannerRenderer
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvent
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.roommembermoderation.api.ModerationAction
|
||||
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
|
||||
import io.element.android.features.roommembermoderation.api.RoomMemberModerationRenderer
|
||||
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
|
||||
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
|
||||
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.designsystem.utils.OnLifecycleEvent
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
|
||||
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.core.toRoomIdOrAlias
|
||||
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.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.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadMessagesUi
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.api.finishLongRunningTransaction
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
@AssistedInject
|
||||
class MessagesNode(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
@ApplicationContext private val context: Context,
|
||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||
private val room: JoinedRoom,
|
||||
private val analyticsService: AnalyticsService,
|
||||
messageComposerPresenterFactory: MessageComposerPresenter.Factory,
|
||||
timelinePresenterFactory: TimelinePresenter.Factory,
|
||||
presenterFactory: MessagesPresenter.Factory,
|
||||
actionListPresenterFactory: ActionListPresenter.Factory,
|
||||
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
|
||||
private val mediaPlayer: MediaPlayer,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer,
|
||||
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
|
||||
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||
data class Inputs(
|
||||
val focusedEventId: EventId?,
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs = inputs<Inputs>()
|
||||
private val callback: Callback = callback()
|
||||
|
||||
private val timelineController = TimelineController(room, room.liveTimeline)
|
||||
private val presenter = presenterFactory.create(
|
||||
navigator = this,
|
||||
composerPresenter = messageComposerPresenterFactory.create(timelineController, this),
|
||||
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
|
||||
actionListPresenter = actionListPresenterFactory.create(
|
||||
postProcessor = TimelineItemActionPostProcessor.Default,
|
||||
timelineMode = timelineController.mainTimelineMode(),
|
||||
),
|
||||
timelineController = timelineController,
|
||||
)
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean
|
||||
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
|
||||
fun navigateToRoomMemberDetails(userId: UserId)
|
||||
fun handlePermalinkClick(data: PermalinkData)
|
||||
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||
fun forwardEvent(eventId: EventId)
|
||||
fun navigateToReportMessage(eventId: EventId, senderId: UserId)
|
||||
fun navigateToSendLocation()
|
||||
fun navigateToCreatePoll()
|
||||
fun navigateToEditPoll(eventId: EventId)
|
||||
fun navigateToRoomCall(roomId: RoomId)
|
||||
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
||||
fun navigateToRoomDetails()
|
||||
fun navigateToPinnedMessagesList()
|
||||
fun navigateToKnockRequestsList()
|
||||
}
|
||||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
lifecycle.subscribe(
|
||||
onCreate = {
|
||||
sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) }
|
||||
},
|
||||
onResume = {
|
||||
analyticsService.finishLongRunningTransaction(LoadMessagesUi)
|
||||
},
|
||||
onDestroy = {
|
||||
mediaPlayer.close()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun onLinkClick(
|
||||
activity: Activity,
|
||||
darkTheme: Boolean,
|
||||
url: String,
|
||||
eventSink: (TimelineEvents) -> Unit,
|
||||
customTab: Boolean
|
||||
) {
|
||||
when (val permalink = permalinkParser.parse(url)) {
|
||||
is PermalinkData.UserLink -> {
|
||||
// Open the room member profile, it will fallback to
|
||||
// the user profile if the user is not in the room
|
||||
callback.navigateToRoomMemberDetails(permalink.userId)
|
||||
}
|
||||
is PermalinkData.RoomLink -> {
|
||||
handleRoomLinkClick(permalink, eventSink)
|
||||
}
|
||||
is PermalinkData.FallbackLink -> {
|
||||
if (customTab) {
|
||||
activity.openUrlInChromeCustomTab(null, darkTheme, url)
|
||||
} else {
|
||||
activity.openUrlInExternalApp(url)
|
||||
}
|
||||
}
|
||||
is PermalinkData.RoomEmailInviteLink -> {
|
||||
activity.openUrlInChromeCustomTab(null, darkTheme, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRoomLinkClick(
|
||||
roomLink: PermalinkData.RoomLink,
|
||||
eventSink: (TimelineEvents) -> Unit,
|
||||
) {
|
||||
if (room.matches(roomLink.roomIdOrAlias)) {
|
||||
val eventId = roomLink.eventId
|
||||
if (eventId != null) {
|
||||
eventSink(TimelineEvents.FocusOnEvent(eventId))
|
||||
} else {
|
||||
// Click on the same room, ignore
|
||||
displaySameRoomToast()
|
||||
}
|
||||
} else {
|
||||
callback.handlePermalinkClick(roomLink)
|
||||
}
|
||||
}
|
||||
|
||||
override fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||
callback.navigateToEventDebugInfo(eventId, debugInfo)
|
||||
}
|
||||
|
||||
override fun forwardEvent(eventId: EventId) {
|
||||
callback.forwardEvent(eventId)
|
||||
}
|
||||
|
||||
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
|
||||
callback.navigateToReportMessage(eventId, senderId)
|
||||
}
|
||||
|
||||
override fun navigateToEditPoll(eventId: EventId) {
|
||||
callback.navigateToEditPoll(eventId)
|
||||
}
|
||||
|
||||
override fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) {
|
||||
callback.navigateToPreviewAttachments(attachments, inReplyToEventId)
|
||||
}
|
||||
|
||||
override fun navigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List<String>) {
|
||||
if (roomId == room.roomId) {
|
||||
displaySameRoomToast()
|
||||
} else {
|
||||
val permalinkData = PermalinkData.RoomLink(roomId.toRoomIdOrAlias(), eventId, viaParameters = serverNames.toImmutableList())
|
||||
callback.handlePermalinkClick(permalinkData)
|
||||
}
|
||||
}
|
||||
|
||||
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
||||
callback.navigateToThread(threadRootId, focusedEventId)
|
||||
}
|
||||
|
||||
private fun displaySameRoomToast() {
|
||||
context.toast(CommonStrings.screen_room_permalink_same_room_android)
|
||||
}
|
||||
|
||||
override fun close() = navigateUp()
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val activity = requireNotNull(LocalActivity.current)
|
||||
val isDark = ElementTheme.isLightTheme.not()
|
||||
CompositionLocalProvider(
|
||||
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
|
||||
) {
|
||||
val state = presenter.present()
|
||||
|
||||
BackHandler {
|
||||
state.eventSink(MessagesEvents.MarkAsFullyReadAndExit)
|
||||
}
|
||||
|
||||
OnLifecycleEvent { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> state.composerState.eventSink(MessageComposerEvent.SaveDraft)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
MessagesView(
|
||||
state = state,
|
||||
onBackClick = { state.eventSink(MessagesEvents.MarkAsFullyReadAndExit) },
|
||||
onRoomDetailsClick = callback::navigateToRoomDetails,
|
||||
onEventContentClick = { isLive, event ->
|
||||
if (isLive) {
|
||||
callback.handleEventClick(timelineController.mainTimelineMode(), event)
|
||||
} else {
|
||||
val detachedTimelineMode = timelineController.detachedTimelineMode()
|
||||
if (detachedTimelineMode != null) {
|
||||
callback.handleEventClick(detachedTimelineMode, event)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
},
|
||||
onUserDataClick = callback::navigateToRoomMemberDetails,
|
||||
onLinkClick = { url, customTab ->
|
||||
onLinkClick(
|
||||
activity = activity,
|
||||
darkTheme = isDark,
|
||||
url = url,
|
||||
eventSink = state.timelineState.eventSink,
|
||||
customTab = customTab,
|
||||
)
|
||||
},
|
||||
onSendLocationClick = callback::navigateToSendLocation,
|
||||
onCreatePollClick = callback::navigateToCreatePoll,
|
||||
onJoinCallClick = { callback.navigateToRoomCall(room.roomId) },
|
||||
onViewAllPinnedMessagesClick = callback::navigateToPinnedMessagesList,
|
||||
modifier = modifier,
|
||||
knockRequestsBannerView = {
|
||||
knockRequestsBannerRenderer.View(
|
||||
modifier = Modifier,
|
||||
onViewRequestsClick = callback::navigateToKnockRequestsList,
|
||||
)
|
||||
},
|
||||
)
|
||||
roomMemberModerationRenderer.Render(
|
||||
state = state.roomMemberModerationState,
|
||||
onSelectAction = { action, target ->
|
||||
when (action) {
|
||||
is ModerationAction.DisplayProfile -> callback.navigateToRoomMemberDetails(target.userId)
|
||||
else -> state.roomMemberModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(action, target))
|
||||
}
|
||||
},
|
||||
modifier = Modifier,
|
||||
)
|
||||
|
||||
var focusedEventId by rememberSaveable {
|
||||
mutableStateOf(inputs.focusedEventId)
|
||||
}
|
||||
LaunchedEffect(focusedEventId) {
|
||||
if (focusedEventId != null) {
|
||||
state.timelineState.eventSink(TimelineEvents.FocusOnEvent(focusedEventId!!))
|
||||
focusedEventId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+586
@@ -0,0 +1,586 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.PinUnpinAction
|
||||
import io.element.android.appconfig.MessageComposerConfig
|
||||
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||
import io.element.android.features.messages.impl.link.LinkState
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvent
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
||||
import io.element.android.features.messages.impl.timeline.MarkAsFullyRead
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
|
||||
import io.element.android.features.roomcall.api.RoomCallState
|
||||
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
|
||||
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.extensions.flatMap
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.toThreadId
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
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.MessageEventType
|
||||
import io.element.android.libraries.matrix.api.room.RoomInfo
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.isDm
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.map
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
|
||||
import io.element.android.libraries.recentemojis.api.AddRecentEmoji
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@AssistedInject
|
||||
class MessagesPresenter(
|
||||
@Assisted private val navigator: MessagesNavigator,
|
||||
private val room: JoinedRoom,
|
||||
@Assisted private val composerPresenter: Presenter<MessageComposerState>,
|
||||
voiceMessageComposerPresenterFactory: DefaultVoiceMessageComposerPresenter.Factory,
|
||||
@Assisted private val timelinePresenter: Presenter<TimelineState>,
|
||||
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
|
||||
private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
|
||||
private val linkPresenter: Presenter<LinkState>,
|
||||
@Assisted private val actionListPresenter: Presenter<ActionListState>,
|
||||
private val customReactionPresenter: Presenter<CustomReactionState>,
|
||||
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
|
||||
private val readReceiptBottomSheetPresenter: Presenter<ReadReceiptBottomSheetState>,
|
||||
private val pinnedMessagesBannerPresenter: Presenter<PinnedMessagesBannerState>,
|
||||
private val roomCallStatePresenter: Presenter<RoomCallState>,
|
||||
private val roomMemberModerationPresenter: Presenter<RoomMemberModerationState>,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val clipboardHelper: ClipboardHelper,
|
||||
private val htmlConverterProvider: HtmlConverterProvider,
|
||||
private val buildMeta: BuildMeta,
|
||||
@Assisted private val timelineController: TimelineController,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val encryptionService: EncryptionService,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val addRecentEmoji: AddRecentEmoji,
|
||||
private val markAsFullyRead: MarkAsFullyRead,
|
||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||
) : Presenter<MessagesState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
navigator: MessagesNavigator,
|
||||
composerPresenter: Presenter<MessageComposerState>,
|
||||
timelinePresenter: Presenter<TimelineState>,
|
||||
actionListPresenter: Presenter<ActionListState>,
|
||||
timelineController: TimelineController,
|
||||
): MessagesPresenter
|
||||
}
|
||||
|
||||
private val voiceMessageComposerPresenter = voiceMessageComposerPresenterFactory.create(
|
||||
timelineMode = timelineController.mainTimelineMode()
|
||||
)
|
||||
|
||||
private val markingAsReadAndExiting = AtomicBoolean(false)
|
||||
|
||||
@Composable
|
||||
override fun present(): MessagesState {
|
||||
htmlConverterProvider.Update()
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val roomInfo by room.roomInfoFlow.collectAsState()
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val composerState = composerPresenter.present()
|
||||
val voiceMessageComposerState = voiceMessageComposerPresenter.present()
|
||||
val timelineState = timelinePresenter.present()
|
||||
val timelineProtectionState = timelineProtectionPresenter.present()
|
||||
val identityChangeState = identityChangeStatePresenter.present()
|
||||
val actionListState = actionListPresenter.present()
|
||||
val linkState = linkPresenter.present()
|
||||
val customReactionState = customReactionPresenter.present()
|
||||
val reactionSummaryState = reactionSummaryPresenter.present()
|
||||
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
|
||||
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
|
||||
val roomCallState = roomCallStatePresenter.present()
|
||||
val roomMemberModerationState = roomMemberModerationPresenter.present()
|
||||
|
||||
val userEventPermissions by userEventPermissions(roomInfo)
|
||||
|
||||
val roomAvatar by remember {
|
||||
derivedStateOf { roomInfo.avatarData() }
|
||||
}
|
||||
val heroes by remember {
|
||||
derivedStateOf { roomInfo.heroes().toImmutableList() }
|
||||
}
|
||||
|
||||
var hasDismissedInviteDialog by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
// Remove the unread flag on entering but don't send read receipts
|
||||
// as those will be handled by the timeline.
|
||||
withContext(dispatchers.io) {
|
||||
room.setUnreadFlag(isUnread = false)
|
||||
|
||||
// If for some reason the encryption state is unknown, fetch it
|
||||
if (roomInfo.isEncrypted == null) {
|
||||
room.getUpdatedIsEncrypted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val inviteProgress = remember { mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized) }
|
||||
var showReinvitePrompt by remember { mutableStateOf(false) }
|
||||
val composerHasFocus by remember { derivedStateOf { composerState.textEditorState.hasFocus() } }
|
||||
LaunchedEffect(hasDismissedInviteDialog, composerHasFocus, roomInfo) {
|
||||
withContext(dispatchers.io) {
|
||||
showReinvitePrompt = !hasDismissedInviteDialog && composerHasFocus && roomInfo.isDm && roomInfo.activeMembersCount == 1L
|
||||
}
|
||||
}
|
||||
|
||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||
|
||||
var dmUserVerificationState by remember { mutableStateOf<IdentityState?>(null) }
|
||||
|
||||
val membersState by room.membersStateFlow.collectAsState()
|
||||
val dmRoomMember by room.getDirectRoomMember(membersState)
|
||||
val roomMemberIdentityStateChanges = identityChangeState.roomMemberIdentityStateChanges
|
||||
|
||||
LifecycleResumeEffect(dmRoomMember, roomInfo.isEncrypted) {
|
||||
if (roomInfo.isEncrypted == true) {
|
||||
val dmRoomMemberId = dmRoomMember?.userId
|
||||
localCoroutineScope.launch {
|
||||
dmRoomMemberId?.let { userId ->
|
||||
dmUserVerificationState = roomMemberIdentityStateChanges.find { it.identityRoomMember.userId == userId }?.identityState
|
||||
?: encryptionService.getUserIdentity(userId).getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
fun handleEvent(event: MessagesEvents) {
|
||||
when (event) {
|
||||
is MessagesEvents.HandleAction -> {
|
||||
localCoroutineScope.handleTimelineAction(
|
||||
action = event.action,
|
||||
targetEvent = event.event,
|
||||
composerState = composerState,
|
||||
enableTextFormatting = composerState.showTextFormatting,
|
||||
timelineState = timelineState,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
)
|
||||
}
|
||||
is MessagesEvents.ToggleReaction -> {
|
||||
localCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId)
|
||||
}
|
||||
is MessagesEvents.InviteDialogDismissed -> {
|
||||
hasDismissedInviteDialog = true
|
||||
|
||||
if (event.action == InviteDialogAction.Invite) {
|
||||
localCoroutineScope.reinviteOtherUser(inviteProgress)
|
||||
}
|
||||
}
|
||||
is MessagesEvents.Dismiss -> actionListState.eventSink(ActionListEvents.Clear)
|
||||
is MessagesEvents.OnUserClicked -> {
|
||||
roomMemberModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.user))
|
||||
}
|
||||
is MessagesEvents.MarkAsFullyReadAndExit -> coroutineScope.launch {
|
||||
if (!markingAsReadAndExiting.getAndSet(true)) {
|
||||
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
|
||||
Timber.w(it, "Failed to get latest event id to mark as fully read")
|
||||
navigator.close()
|
||||
return@launch
|
||||
}
|
||||
latestEventId?.let { eventId ->
|
||||
sessionCoroutineScope.launch {
|
||||
markAsFullyRead(room.roomId, eventId)
|
||||
}
|
||||
}
|
||||
navigator.close()
|
||||
markingAsReadAndExiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return MessagesState(
|
||||
roomId = room.roomId,
|
||||
roomName = roomInfo.name,
|
||||
roomAvatar = roomAvatar,
|
||||
heroes = heroes,
|
||||
userEventPermissions = userEventPermissions,
|
||||
composerState = composerState,
|
||||
voiceMessageComposerState = voiceMessageComposerState,
|
||||
timelineState = timelineState,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
identityChangeState = identityChangeState,
|
||||
linkState = linkState,
|
||||
actionListState = actionListState,
|
||||
customReactionState = customReactionState,
|
||||
reactionSummaryState = reactionSummaryState,
|
||||
readReceiptBottomSheetState = readReceiptBottomSheetState,
|
||||
snackbarMessage = snackbarMessage,
|
||||
inviteProgress = inviteProgress.value,
|
||||
showReinvitePrompt = showReinvitePrompt,
|
||||
enableTextFormatting = MessageComposerConfig.ENABLE_RICH_TEXT_EDITING,
|
||||
roomCallState = roomCallState,
|
||||
appName = buildMeta.applicationName,
|
||||
pinnedMessagesBannerState = pinnedMessagesBannerState,
|
||||
dmUserVerificationState = dmUserVerificationState,
|
||||
roomMemberModerationState = roomMemberModerationState,
|
||||
successorRoom = roomInfo.successorRoom,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun userEventPermissions(roomInfo: RoomInfo): State<UserEventPermissions> {
|
||||
val key = if (roomInfo.privilegedCreatorRole && roomInfo.creators.contains(room.sessionId)) {
|
||||
Long.MAX_VALUE
|
||||
} else {
|
||||
roomInfo.roomPowerLevels?.hashCode() ?: 0L
|
||||
}
|
||||
return produceState(UserEventPermissions.DEFAULT, key1 = key) {
|
||||
value = UserEventPermissions(
|
||||
canSendMessage = room.canSendMessage(type = MessageEventType.RoomMessage).getOrElse { true },
|
||||
canSendReaction = room.canSendMessage(type = MessageEventType.Reaction).getOrElse { true },
|
||||
canRedactOwn = room.canRedactOwn().getOrElse { false },
|
||||
canRedactOther = room.canRedactOther().getOrElse { false },
|
||||
canPinUnpin = room.canPinUnpin().getOrElse { false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun RoomInfo.avatarData(): AvatarData {
|
||||
return AvatarData(
|
||||
id = id.value,
|
||||
name = name,
|
||||
url = avatarUrl,
|
||||
size = AvatarSize.TimelineRoom
|
||||
)
|
||||
}
|
||||
|
||||
private fun RoomInfo.heroes(): List<AvatarData> {
|
||||
return heroes.map { user ->
|
||||
user.getAvatarData(size = AvatarSize.TimelineRoom)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.handleTimelineAction(
|
||||
action: TimelineItemAction,
|
||||
targetEvent: TimelineItem.Event,
|
||||
composerState: MessageComposerState,
|
||||
timelineProtectionState: TimelineProtectionState,
|
||||
enableTextFormatting: Boolean,
|
||||
timelineState: TimelineState,
|
||||
) = launch {
|
||||
when (action) {
|
||||
TimelineItemAction.CopyText -> handleCopyContents(targetEvent)
|
||||
TimelineItemAction.CopyCaption -> handleCopyCaption(targetEvent)
|
||||
TimelineItemAction.CopyLink -> handleCopyLink(targetEvent)
|
||||
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
|
||||
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
|
||||
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState)
|
||||
TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)
|
||||
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState, timelineProtectionState)
|
||||
TimelineItemAction.ReplyInThread -> {
|
||||
val displayThreads = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
|
||||
if (displayThreads) {
|
||||
// Get either the thread id this event is in, or the event id if it's not in a thread so we can start one
|
||||
val threadId = when (targetEvent.threadInfo) {
|
||||
is TimelineItemThreadInfo.ThreadResponse -> targetEvent.threadInfo.threadRootId
|
||||
is TimelineItemThreadInfo.ThreadRoot, null -> targetEvent.eventId?.toThreadId()
|
||||
} ?: return@launch
|
||||
navigator.navigateToThread(threadId, null)
|
||||
} else {
|
||||
handleActionReply(targetEvent, composerState, timelineProtectionState)
|
||||
}
|
||||
}
|
||||
TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent)
|
||||
TimelineItemAction.Forward -> handleForwardAction(targetEvent)
|
||||
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
|
||||
TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState)
|
||||
TimelineItemAction.Pin -> handlePinAction(targetEvent)
|
||||
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
|
||||
TimelineItemAction.ViewInTimeline -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleRemoveCaption(targetEvent: TimelineItem.Event) {
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
editCaption(
|
||||
eventOrTransactionId = targetEvent.eventOrTransactionId,
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handlePinAction(targetEvent: TimelineItem.Event) {
|
||||
if (targetEvent.eventId == null) return
|
||||
analyticsService.capture(
|
||||
PinUnpinAction(
|
||||
from = PinUnpinAction.From.Timeline,
|
||||
kind = PinUnpinAction.Kind.Pin,
|
||||
)
|
||||
)
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
pinEvent(targetEvent.eventId)
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to pin event ${targetEvent.eventId}")
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleUnpinAction(targetEvent: TimelineItem.Event) {
|
||||
if (targetEvent.eventId == null) return
|
||||
analyticsService.capture(
|
||||
PinUnpinAction(
|
||||
from = PinUnpinAction.From.Timeline,
|
||||
kind = PinUnpinAction.Kind.Unpin,
|
||||
)
|
||||
)
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
unpinEvent(targetEvent.eventId)
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to unpin event ${targetEvent.eventId}")
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.toggleReaction(
|
||||
emoji: String,
|
||||
eventOrTransactionId: EventOrTransactionId,
|
||||
) = launch(dispatchers.io) {
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
toggleReaction(emoji, eventOrTransactionId)
|
||||
.flatMap { added -> if (added) addRecentEmoji(emoji) else Result.success(Unit) }
|
||||
.onFailure { Timber.e(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState<AsyncData<Unit>>) = launch(dispatchers.io) {
|
||||
inviteProgress.value = AsyncData.Loading()
|
||||
runCatchingExceptions {
|
||||
val memberList = when (val memberState = room.membersStateFlow.value) {
|
||||
is RoomMembersState.Ready -> memberState.roomMembers
|
||||
is RoomMembersState.Error -> memberState.prevRoomMembers.orEmpty()
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
val member = memberList.first { it.userId != room.sessionId }
|
||||
room.inviteUserById(member.userId).onFailure { t ->
|
||||
Timber.e(t, "Failed to reinvite DM partner")
|
||||
}.getOrThrow()
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
inviteProgress.value = AsyncData.Success(Unit)
|
||||
},
|
||||
onFailure = {
|
||||
inviteProgress.value = AsyncData.Failure(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun handleActionRedact(event: TimelineItem.Event) {
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
redactEvent(eventOrTransactionId = event.eventOrTransactionId, reason = null)
|
||||
.onFailure { Timber.e(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleActionEdit(
|
||||
targetEvent: TimelineItem.Event,
|
||||
composerState: MessageComposerState,
|
||||
enableTextFormatting: Boolean,
|
||||
) {
|
||||
when (targetEvent.content) {
|
||||
is TimelineItemPollContent -> {
|
||||
if (targetEvent.eventId == null) return
|
||||
navigator.navigateToEditPoll(targetEvent.eventId)
|
||||
}
|
||||
else -> {
|
||||
val composerMode = MessageComposerMode.Edit(
|
||||
targetEvent.eventOrTransactionId,
|
||||
(targetEvent.content as? TimelineItemTextBasedContent)?.let {
|
||||
if (enableTextFormatting) {
|
||||
it.htmlBody ?: it.body
|
||||
} else {
|
||||
it.body
|
||||
}
|
||||
}.orEmpty(),
|
||||
)
|
||||
composerState.eventSink(
|
||||
MessageComposerEvent.SetMode(composerMode)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleActionAddCaption(
|
||||
targetEvent: TimelineItem.Event,
|
||||
composerState: MessageComposerState,
|
||||
) {
|
||||
val composerMode = MessageComposerMode.EditCaption(
|
||||
eventOrTransactionId = targetEvent.eventOrTransactionId,
|
||||
content = "",
|
||||
)
|
||||
composerState.eventSink(
|
||||
MessageComposerEvent.SetMode(composerMode)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun handleActionEditCaption(
|
||||
targetEvent: TimelineItem.Event,
|
||||
composerState: MessageComposerState,
|
||||
) {
|
||||
val composerMode = MessageComposerMode.EditCaption(
|
||||
eventOrTransactionId = targetEvent.eventOrTransactionId,
|
||||
content = (targetEvent.content as? TimelineItemEventContentWithAttachment)?.caption.orEmpty(),
|
||||
)
|
||||
composerState.eventSink(
|
||||
MessageComposerEvent.SetMode(composerMode)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun handleActionReply(
|
||||
targetEvent: TimelineItem.Event,
|
||||
composerState: MessageComposerState,
|
||||
timelineProtectionState: TimelineProtectionState,
|
||||
) {
|
||||
if (targetEvent.eventId == null) return
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
val replyToDetails = loadReplyDetails(targetEvent.eventId).map(permalinkParser)
|
||||
val composerMode = MessageComposerMode.Reply(
|
||||
replyToDetails = replyToDetails,
|
||||
hideImage = timelineProtectionState.hideMediaContent(targetEvent.eventId),
|
||||
)
|
||||
composerState.eventSink(
|
||||
MessageComposerEvent.SetMode(composerMode)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleShowDebugInfoAction(event: TimelineItem.Event) {
|
||||
navigator.navigateToEventDebugInfo(event.eventId, event.debugInfo)
|
||||
}
|
||||
|
||||
private fun handleForwardAction(event: TimelineItem.Event) {
|
||||
if (event.eventId == null) return
|
||||
navigator.forwardEvent(event.eventId)
|
||||
}
|
||||
|
||||
private fun handleReportAction(event: TimelineItem.Event) {
|
||||
if (event.eventId == null) return
|
||||
navigator.navigateToReportMessage(event.eventId, event.senderId)
|
||||
}
|
||||
|
||||
private fun handleEndPollAction(
|
||||
event: TimelineItem.Event,
|
||||
timelineState: TimelineState,
|
||||
) {
|
||||
event.eventId?.let { timelineState.eventSink(TimelineEvents.EndPoll(it)) }
|
||||
}
|
||||
|
||||
private suspend fun handleCopyLink(event: TimelineItem.Event) {
|
||||
event.eventId ?: return
|
||||
room.getPermalinkFor(event.eventId).fold(
|
||||
onSuccess = { permalink ->
|
||||
clipboardHelper.copyPlainText(permalink)
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_link_copied_to_clipboard))
|
||||
},
|
||||
onFailure = {
|
||||
Timber.e(it, "Failed to get permalink for event ${event.eventId}")
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleCopyContents(event: TimelineItem.Event) {
|
||||
val content = when (event.content) {
|
||||
is TimelineItemTextBasedContent -> event.content.body
|
||||
is TimelineItemStateContent -> event.content.body
|
||||
else -> return
|
||||
}
|
||||
clipboardHelper.copyPlainText(content)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_message_copied))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCopyCaption(event: TimelineItem.Event) {
|
||||
val content = (event.content as? TimelineItemEventContentWithAttachment)?.caption ?: return
|
||||
clipboardHelper.copyPlainText(content)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||
import io.element.android.features.messages.impl.link.LinkState
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
|
||||
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.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.RoomId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class MessagesState(
|
||||
val roomId: RoomId,
|
||||
val roomName: String?,
|
||||
val roomAvatar: AvatarData,
|
||||
val heroes: ImmutableList<AvatarData>,
|
||||
val userEventPermissions: UserEventPermissions,
|
||||
val composerState: MessageComposerState,
|
||||
val voiceMessageComposerState: VoiceMessageComposerState,
|
||||
val timelineState: TimelineState,
|
||||
val timelineProtectionState: TimelineProtectionState,
|
||||
val identityChangeState: IdentityChangeState,
|
||||
val linkState: LinkState,
|
||||
val actionListState: ActionListState,
|
||||
val customReactionState: CustomReactionState,
|
||||
val reactionSummaryState: ReactionSummaryState,
|
||||
val readReceiptBottomSheetState: ReadReceiptBottomSheetState,
|
||||
val snackbarMessage: SnackbarMessage?,
|
||||
val inviteProgress: AsyncData<Unit>,
|
||||
val showReinvitePrompt: Boolean,
|
||||
val enableTextFormatting: Boolean,
|
||||
val roomCallState: RoomCallState,
|
||||
val appName: String,
|
||||
val pinnedMessagesBannerState: PinnedMessagesBannerState,
|
||||
val dmUserVerificationState: IdentityState?,
|
||||
val roomMemberModerationState: RoomMemberModerationState,
|
||||
val successorRoom: SuccessorRoom?,
|
||||
val eventSink: (MessagesEvents) -> Unit
|
||||
) {
|
||||
val isTombstoned = successorRoom != null
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
|
||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState
|
||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessagePreviewState
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||
import io.element.android.features.messages.impl.actionlist.anActionListState
|
||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
|
||||
import io.element.android.features.messages.impl.link.LinkState
|
||||
import io.element.android.features.messages.impl.link.aLinkState
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
||||
import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemList
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineState
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
|
||||
import io.element.android.features.roomcall.api.RoomCallState
|
||||
import io.element.android.features.roomcall.api.aStandByCallState
|
||||
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
|
||||
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
|
||||
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.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||
override val values: Sequence<MessagesState>
|
||||
get() = sequenceOf(
|
||||
aMessagesState(),
|
||||
aMessagesState(composerState = aMessageComposerState(showAttachmentSourcePicker = true)),
|
||||
aMessagesState(userEventPermissions = aUserEventPermissions(canSendMessage = false)),
|
||||
aMessagesState(showReinvitePrompt = true),
|
||||
aMessagesState(composerState = aMessageComposerState(showTextFormatting = true)),
|
||||
aMessagesState(
|
||||
voiceMessageComposerState = aVoiceMessageComposerState(showPermissionRationaleDialog = true),
|
||||
),
|
||||
aMessagesState(
|
||||
voiceMessageComposerState = aVoiceMessageComposerState(
|
||||
voiceMessageState = aVoiceMessagePreviewState(),
|
||||
showSendFailureDialog = true
|
||||
),
|
||||
),
|
||||
aMessagesState(
|
||||
pinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(
|
||||
knownPinnedMessagesCount = 4,
|
||||
currentPinnedMessageIndex = 0,
|
||||
),
|
||||
),
|
||||
aMessagesState(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)),
|
||||
aMessagesState(
|
||||
timelineState = aTimelineState(
|
||||
timelineMode = Timeline.Mode.Thread(threadRootId = ThreadId("\$a-thread-id")),
|
||||
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aMessagesState(
|
||||
roomName: String? = "Room name",
|
||||
roomAvatar: AvatarData = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom),
|
||||
userEventPermissions: UserEventPermissions = aUserEventPermissions(),
|
||||
composerState: MessageComposerState = aMessageComposerState(
|
||||
textEditorState = aTextEditorStateRich(initialText = "Hello", initialFocus = true),
|
||||
isFullScreen = false,
|
||||
mode = MessageComposerMode.Normal,
|
||||
),
|
||||
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
|
||||
timelineState: TimelineState = aTimelineState(
|
||||
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
||||
// Render a focused event for an event with sender information displayed
|
||||
focusedEventIndex = 2,
|
||||
),
|
||||
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
|
||||
identityChangeState: IdentityChangeState = anIdentityChangeState(),
|
||||
linkState: LinkState = aLinkState(),
|
||||
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
|
||||
actionListState: ActionListState = anActionListState(),
|
||||
customReactionState: CustomReactionState = aCustomReactionState(),
|
||||
reactionSummaryState: ReactionSummaryState = aReactionSummaryState(),
|
||||
showReinvitePrompt: Boolean = false,
|
||||
roomCallState: RoomCallState = aStandByCallState(),
|
||||
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
|
||||
dmUserVerificationState: IdentityState? = null,
|
||||
roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(),
|
||||
successorRoom: SuccessorRoom? = null,
|
||||
eventSink: (MessagesEvents) -> Unit = {},
|
||||
) = MessagesState(
|
||||
roomId = RoomId("!id:domain"),
|
||||
roomName = roomName,
|
||||
roomAvatar = roomAvatar,
|
||||
heroes = persistentListOf(),
|
||||
userEventPermissions = userEventPermissions,
|
||||
composerState = composerState,
|
||||
voiceMessageComposerState = voiceMessageComposerState,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
identityChangeState = identityChangeState,
|
||||
linkState = linkState,
|
||||
timelineState = timelineState,
|
||||
readReceiptBottomSheetState = readReceiptBottomSheetState,
|
||||
actionListState = actionListState,
|
||||
customReactionState = customReactionState,
|
||||
reactionSummaryState = reactionSummaryState,
|
||||
snackbarMessage = null,
|
||||
inviteProgress = AsyncData.Uninitialized,
|
||||
showReinvitePrompt = showReinvitePrompt,
|
||||
enableTextFormatting = true,
|
||||
roomCallState = roomCallState,
|
||||
appName = "Element",
|
||||
pinnedMessagesBannerState = pinnedMessagesBannerState,
|
||||
dmUserVerificationState = dmUserVerificationState,
|
||||
roomMemberModerationState = roomMemberModerationState,
|
||||
successorRoom = successorRoom,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
fun aRoomMemberModerationState(
|
||||
canKick: Boolean = false,
|
||||
canBan: Boolean = false,
|
||||
) = object : RoomMemberModerationState {
|
||||
override val canKick: Boolean = canKick
|
||||
override val canBan: Boolean = canBan
|
||||
override val eventSink: (RoomMemberModerationEvents) -> Unit = {}
|
||||
}
|
||||
|
||||
fun aUserEventPermissions(
|
||||
canRedactOwn: Boolean = false,
|
||||
canRedactOther: Boolean = false,
|
||||
canSendMessage: Boolean = true,
|
||||
canSendReaction: Boolean = true,
|
||||
canPinUnpin: Boolean = false,
|
||||
) = UserEventPermissions(
|
||||
canRedactOwn = canRedactOwn,
|
||||
canRedactOther = canRedactOther,
|
||||
canSendMessage = canSendMessage,
|
||||
canSendReaction = canSendReaction,
|
||||
canPinUnpin = canPinUnpin,
|
||||
)
|
||||
|
||||
fun aReactionSummaryState(
|
||||
target: ReactionSummaryState.Summary? = null,
|
||||
eventSink: (ReactionSummaryEvents) -> Unit = {}
|
||||
) = ReactionSummaryState(
|
||||
target = target,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
fun aCustomReactionState(
|
||||
target: CustomReactionState.Target = CustomReactionState.Target.None,
|
||||
recentEmojis: ImmutableList<String> = persistentListOf(),
|
||||
eventSink: (CustomReactionEvents) -> Unit = {},
|
||||
) = CustomReactionState(
|
||||
target = target,
|
||||
recentEmojis = recentEmojis,
|
||||
selectedEmoji = persistentSetOf(),
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
fun aReadReceiptBottomSheetState(
|
||||
selectedEvent: TimelineItem.Event? = null,
|
||||
eventSink: (ReadReceiptBottomSheetEvents) -> Unit = {},
|
||||
) = ReadReceiptBottomSheetState(
|
||||
selectedEvent = selectedEvent,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
+552
@@ -0,0 +1,552 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListView
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView
|
||||
import io.element.android.features.messages.impl.link.LinkEvents
|
||||
import io.element.android.features.messages.impl.link.LinkView
|
||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
|
||||
import io.element.android.features.messages.impl.messagecomposer.DisabledComposerView
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvent
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
|
||||
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsPickerView
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults
|
||||
import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelineView
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.topbars.MessagesViewTopBar
|
||||
import io.element.android.features.messages.impl.topbars.ThreadTopBar
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
|
||||
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
|
||||
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.rememberExpandableBottomSheetLayoutState
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toAnnotatedString
|
||||
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.utils.HideKeyboardWhenDisposed
|
||||
import io.element.android.libraries.designsystem.utils.KeepScreenOn
|
||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||
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.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
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.user.MatrixUser
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
fun MessagesView(
|
||||
state: MessagesState,
|
||||
onBackClick: () -> Unit,
|
||||
onRoomDetailsClick: () -> Unit,
|
||||
onEventContentClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClick: (String, Boolean) -> Unit,
|
||||
onSendLocationClick: () -> Unit,
|
||||
onCreatePollClick: () -> Unit,
|
||||
onJoinCallClick: () -> Unit,
|
||||
onViewAllPinnedMessagesClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
forceJumpToBottomVisibility: Boolean = false,
|
||||
knockRequestsBannerView: @Composable () -> Unit,
|
||||
) {
|
||||
OnLifecycleEvent { _, event ->
|
||||
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.LifecycleEvent(event))
|
||||
}
|
||||
|
||||
KeepScreenOn(state.voiceMessageComposerState.keepScreenOn)
|
||||
|
||||
HideKeyboardWhenDisposed()
|
||||
|
||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||
|
||||
// This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
|
||||
val localView = LocalView.current
|
||||
|
||||
fun hidingKeyboard(block: () -> Unit) {
|
||||
localView.hideKeyboard()
|
||||
block()
|
||||
}
|
||||
|
||||
fun onContentClick(event: TimelineItem.Event) {
|
||||
Timber.v("onMessageClick= ${event.id}")
|
||||
val hideKeyboard = onEventContentClick(state.timelineState.isLive, event)
|
||||
if (hideKeyboard) {
|
||||
localView.hideKeyboard()
|
||||
}
|
||||
}
|
||||
|
||||
fun onMessageLongClick(event: TimelineItem.Event) {
|
||||
Timber.v("OnMessageLongClicked= ${event.id}")
|
||||
hidingKeyboard {
|
||||
state.actionListState.eventSink(
|
||||
ActionListEvents.ComputeForMessage(
|
||||
event = event,
|
||||
userEventPermissions = state.userEventPermissions,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
|
||||
state.eventSink(MessagesEvents.HandleAction(action, event))
|
||||
}
|
||||
|
||||
fun onEmojiReactionClick(emoji: String, event: TimelineItem.Event) {
|
||||
state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventOrTransactionId))
|
||||
}
|
||||
|
||||
fun onEmojiReactionLongClick(emoji: String, event: TimelineItem.Event) {
|
||||
if (event.eventId == null) return
|
||||
state.reactionSummaryState.eventSink(ReactionSummaryEvents.ShowReactionSummary(event.eventId, event.reactionsState.reactions, emoji))
|
||||
}
|
||||
|
||||
fun onMoreReactionsClick(event: TimelineItem.Event) {
|
||||
state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
|
||||
}
|
||||
|
||||
val expandableState = rememberExpandableBottomSheetLayoutState()
|
||||
ExpandableBottomSheetLayout(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.systemBarsPadding(),
|
||||
content = {
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets.statusBars,
|
||||
topBar = {
|
||||
if (state.timelineState.timelineMode is Timeline.Mode.Thread) {
|
||||
ThreadTopBar(
|
||||
roomName = state.roomName,
|
||||
roomAvatarData = state.roomAvatar,
|
||||
heroes = state.heroes,
|
||||
isTombstoned = state.isTombstoned,
|
||||
onBackClick = onBackClick,
|
||||
)
|
||||
} else {
|
||||
MessagesViewTopBar(
|
||||
roomName = state.roomName,
|
||||
roomAvatar = state.roomAvatar,
|
||||
isTombstoned = state.isTombstoned,
|
||||
heroes = state.heroes,
|
||||
roomCallState = state.roomCallState,
|
||||
dmUserIdentityState = state.dmUserVerificationState,
|
||||
onBackClick = { hidingKeyboard { onBackClick() } },
|
||||
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
)
|
||||
}
|
||||
},
|
||||
content = { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
) {
|
||||
MessagesViewContent(
|
||||
state = state,
|
||||
onContentClick = ::onContentClick,
|
||||
onMessageLongClick = ::onMessageLongClick,
|
||||
onUserDataClick = {
|
||||
hidingKeyboard {
|
||||
state.eventSink(MessagesEvents.OnUserClicked(it))
|
||||
}
|
||||
},
|
||||
onLinkClick = { link, customTab ->
|
||||
if (customTab) {
|
||||
onLinkClick(link.url, true)
|
||||
// Do not check those links, they are internal link only
|
||||
} else {
|
||||
state.linkState.eventSink(LinkEvents.OnLinkClick(link))
|
||||
}
|
||||
},
|
||||
onReactionClick = ::onEmojiReactionClick,
|
||||
onReactionLongClick = ::onEmojiReactionLongClick,
|
||||
onMoreReactionsClick = ::onMoreReactionsClick,
|
||||
onReadReceiptClick = { event ->
|
||||
state.readReceiptBottomSheetState.eventSink(ReadReceiptBottomSheetEvents.EventSelected(event))
|
||||
},
|
||||
onSendLocationClick = onSendLocationClick,
|
||||
onCreatePollClick = onCreatePollClick,
|
||||
onSwipeToReply = { targetEvent ->
|
||||
state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, targetEvent))
|
||||
},
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
|
||||
knockRequestsBannerView = knockRequestsBannerView,
|
||||
)
|
||||
|
||||
SuggestionsPickerView(
|
||||
modifier = Modifier
|
||||
.shadow(10.dp)
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
.align(Alignment.BottomStart)
|
||||
.heightIn(max = 230.dp),
|
||||
roomId = state.roomId,
|
||||
roomName = state.roomName,
|
||||
roomAvatarData = state.roomAvatar,
|
||||
suggestions = state.composerState.suggestions,
|
||||
onSelectSuggestion = {
|
||||
state.composerState.eventSink(MessageComposerEvent.InsertSuggestion(it))
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = {
|
||||
SnackbarHost(
|
||||
snackbarHostState,
|
||||
modifier = Modifier.navigationBarsPadding()
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomSheetContent = {
|
||||
MessagesViewComposerBottomSheetContents(
|
||||
state = state,
|
||||
onLinkClick = { url, customTab -> onLinkClick(url, customTab) },
|
||||
onRoomSuccessorClick = { roomId ->
|
||||
state.timelineState.eventSink(TimelineEvents.NavigateToPredecessorOrSuccessorRoom(roomId = roomId))
|
||||
},
|
||||
)
|
||||
},
|
||||
sheetDragHandle = @Composable { toggleAction ->
|
||||
if (state.composerState.showTextFormatting) {
|
||||
val expandA11yLabel = stringResource(CommonStrings.a11y_expand_message_text_field)
|
||||
val collapseA11yLabel = stringResource(CommonStrings.a11y_collapse_message_text_field)
|
||||
BottomSheetDragHandle(
|
||||
modifier = Modifier.semantics {
|
||||
role = Role.Button
|
||||
// Accessibility action to toggle the bottom sheet state
|
||||
val label = when (expandableState.position) {
|
||||
ExpandableBottomSheetLayoutState.Position.COLLAPSED, ExpandableBottomSheetLayoutState.Position.DRAGGING -> expandA11yLabel
|
||||
ExpandableBottomSheetLayoutState.Position.EXPANDED -> collapseA11yLabel
|
||||
}
|
||||
onClick(label) {
|
||||
toggleAction()
|
||||
true
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
LaunchedEffect(Unit) {
|
||||
// Ensure that the bottom sheet is collapsed
|
||||
if (expandableState.position == ExpandableBottomSheetLayoutState.Position.EXPANDED) {
|
||||
toggleAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
isSwipeGestureEnabled = state.composerState.showTextFormatting,
|
||||
state = expandableState,
|
||||
sheetShape = if (state.composerState.showTextFormatting || state.composerState.suggestions.isNotEmpty()) {
|
||||
MaterialTheme.shapes.large
|
||||
} else {
|
||||
RectangleShape
|
||||
},
|
||||
maxBottomSheetContentHeight = 360.dp,
|
||||
)
|
||||
|
||||
ActionListView(
|
||||
state = state.actionListState,
|
||||
onSelectAction = ::onActionSelected,
|
||||
onCustomReactionClick = { event ->
|
||||
state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
|
||||
},
|
||||
onEmojiReactionClick = ::onEmojiReactionClick,
|
||||
onVerifiedUserSendFailureClick = { event ->
|
||||
state.timelineState.eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event))
|
||||
},
|
||||
)
|
||||
|
||||
CustomReactionBottomSheet(
|
||||
state = state.customReactionState,
|
||||
onSelectEmoji = { uniqueId, emoji ->
|
||||
state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, uniqueId))
|
||||
}
|
||||
)
|
||||
|
||||
ReactionSummaryView(state = state.reactionSummaryState)
|
||||
ReadReceiptBottomSheet(
|
||||
state = state.readReceiptBottomSheetState,
|
||||
onUserDataClick = onUserDataClick,
|
||||
)
|
||||
ReinviteDialog(state = state)
|
||||
LinkView(
|
||||
onLinkValid = { link ->
|
||||
onLinkClick(link.url, false)
|
||||
},
|
||||
state = state.linkState,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReinviteDialog(state: MessagesState) {
|
||||
if (state.showReinvitePrompt) {
|
||||
ConfirmationDialog(
|
||||
title = stringResource(id = R.string.screen_room_invite_again_alert_title),
|
||||
content = stringResource(id = R.string.screen_room_invite_again_alert_message),
|
||||
cancelText = stringResource(id = CommonStrings.action_cancel),
|
||||
submitText = stringResource(id = CommonStrings.action_invite),
|
||||
onSubmitClick = { state.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) },
|
||||
onDismiss = { state.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Cancel)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessagesViewContent(
|
||||
state: MessagesState,
|
||||
onContentClick: (TimelineItem.Event) -> Unit,
|
||||
onUserDataClick: (MatrixUser) -> Unit,
|
||||
onLinkClick: (Link, Boolean) -> Unit,
|
||||
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
|
||||
onReadReceiptClick: (TimelineItem.Event) -> Unit,
|
||||
onMessageLongClick: (TimelineItem.Event) -> Unit,
|
||||
onSendLocationClick: () -> Unit,
|
||||
onCreatePollClick: () -> Unit,
|
||||
onJoinCallClick: () -> Unit,
|
||||
onViewAllPinnedMessagesClick: () -> Unit,
|
||||
forceJumpToBottomVisibility: Boolean,
|
||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
knockRequestsBannerView: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.imePadding(),
|
||||
) {
|
||||
AttachmentsBottomSheet(
|
||||
state = state.composerState,
|
||||
onSendLocationClick = onSendLocationClick,
|
||||
onCreatePollClick = onCreatePollClick,
|
||||
enableTextFormatting = state.enableTextFormatting,
|
||||
)
|
||||
|
||||
if (state.voiceMessageComposerState.showPermissionRationaleDialog) {
|
||||
VoiceMessagePermissionRationaleDialog(
|
||||
onContinue = {
|
||||
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.AcceptPermissionRationale)
|
||||
},
|
||||
onDismiss = {
|
||||
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.DismissPermissionsRationale)
|
||||
},
|
||||
appName = state.appName
|
||||
)
|
||||
}
|
||||
if (state.voiceMessageComposerState.showSendFailureDialog) {
|
||||
VoiceMessageSendingFailedDialog(
|
||||
onDismiss = { state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.DismissSendFailureDialog) },
|
||||
)
|
||||
}
|
||||
|
||||
Box {
|
||||
val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberScrollBehavior(
|
||||
pinnedMessagesCount = (state.pinnedMessagesBannerState as? PinnedMessagesBannerState.Visible)?.pinnedMessagesCount() ?: 0,
|
||||
)
|
||||
TimelineView(
|
||||
state = state.timelineState,
|
||||
timelineProtectionState = state.timelineProtectionState,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = { link -> onLinkClick(link, false) },
|
||||
onContentClick = onContentClick,
|
||||
onMessageLongClick = onMessageLongClick,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
|
||||
)
|
||||
|
||||
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
|
||||
AnimatedVisibility(
|
||||
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
fun focusOnPinnedEvent(eventId: EventId) {
|
||||
state.timelineState.eventSink(
|
||||
TimelineEvents.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
|
||||
)
|
||||
}
|
||||
PinnedMessagesBannerView(
|
||||
state = state.pinnedMessagesBannerState,
|
||||
onClick = ::focusOnPinnedEvent,
|
||||
onViewAllClick = onViewAllPinnedMessagesClick,
|
||||
)
|
||||
}
|
||||
knockRequestsBannerView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessagesViewComposerBottomSheetContents(
|
||||
state: MessagesState,
|
||||
onRoomSuccessorClick: (RoomId) -> Unit,
|
||||
onLinkClick: (String, Boolean) -> Unit,
|
||||
) {
|
||||
when {
|
||||
state.successorRoom != null -> {
|
||||
SuccessorRoomBanner(roomSuccessor = state.successorRoom, onRoomSuccessorClick = onRoomSuccessorClick)
|
||||
}
|
||||
state.userEventPermissions.canSendMessage -> {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
|
||||
if (state.composerState.suggestions.isEmpty() &&
|
||||
state.composerState.textEditorState is TextEditorState.Markdown) {
|
||||
IdentityChangeStateView(
|
||||
state = state.identityChangeState,
|
||||
onLinkClick = onLinkClick,
|
||||
)
|
||||
}
|
||||
val verificationViolation = state.identityChangeState.roomMemberIdentityStateChanges.firstOrNull {
|
||||
it.identityState == IdentityState.VerificationViolation
|
||||
}
|
||||
if (verificationViolation != null) {
|
||||
DisabledComposerView(modifier = Modifier.fillMaxWidth())
|
||||
} else {
|
||||
MessageComposerView(
|
||||
state = state.composerState,
|
||||
voiceMessageState = state.voiceMessageComposerState,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
CantSendMessageBanner()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CantSendMessageBanner() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(ElementTheme.colors.bgSubtleSecondary)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.screen_room_timeline_no_permission_to_post),
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
fontStyle = FontStyle.Italic,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuccessorRoomBanner(
|
||||
roomSuccessor: SuccessorRoom,
|
||||
onRoomSuccessorClick: (RoomId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ComposerAlertMolecule(
|
||||
avatar = null,
|
||||
content = stringResource(R.string.screen_room_timeline_tombstoned_room_message).toAnnotatedString(),
|
||||
onSubmitClick = { onRoomSuccessorClick(roomSuccessor.roomId) },
|
||||
modifier = modifier,
|
||||
submitText = stringResource(R.string.screen_room_timeline_tombstoned_room_action)
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) = ElementPreview {
|
||||
MessagesView(
|
||||
state = state,
|
||||
onBackClick = {},
|
||||
onRoomDetailsClick = {},
|
||||
onEventContentClick = { _, _ -> false },
|
||||
onUserDataClick = {},
|
||||
onLinkClick = { _, _ -> },
|
||||
onSendLocationClick = {},
|
||||
onCreatePollClick = {},
|
||||
onJoinCallClick = {},
|
||||
onViewAllPinnedMessagesClick = { },
|
||||
forceJumpToBottomVisibility = true,
|
||||
knockRequestsBannerView = {},
|
||||
)
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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
|
||||
|
||||
/**
|
||||
* Represents the permissions a user has in a room.
|
||||
* It's dependent of the user's power level in the room.
|
||||
*/
|
||||
data class UserEventPermissions(
|
||||
val canRedactOwn: Boolean,
|
||||
val canRedactOther: Boolean,
|
||||
val canSendMessage: Boolean,
|
||||
val canSendReaction: Boolean,
|
||||
val canPinUnpin: Boolean,
|
||||
) {
|
||||
companion object {
|
||||
val DEFAULT = UserEventPermissions(
|
||||
canRedactOwn = true,
|
||||
canRedactOther = false,
|
||||
canSendMessage = true,
|
||||
canSendReaction = true,
|
||||
canPinUnpin = false
|
||||
)
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.actionlist
|
||||
|
||||
import io.element.android.features.messages.impl.UserEventPermissions
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
|
||||
sealed interface ActionListEvents {
|
||||
data object Clear : ActionListEvents
|
||||
data class ComputeForMessage(
|
||||
val event: TimelineItem.Event,
|
||||
val userEventPermissions: UserEventPermissions,
|
||||
) : ActionListEvents
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* 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.actionlist
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.messages.impl.UserEventPermissions
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionComparator
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
|
||||
import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded
|
||||
import io.element.android.features.messages.impl.timeline.model.event.canReact
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.recentemojis.api.GetRecentEmojis
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
interface ActionListPresenter : Presenter<ActionListState> {
|
||||
interface Factory {
|
||||
fun create(
|
||||
postProcessor: TimelineItemActionPostProcessor,
|
||||
timelineMode: Timeline.Mode,
|
||||
): ActionListPresenter
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedInject
|
||||
class DefaultActionListPresenter(
|
||||
@Assisted
|
||||
private val postProcessor: TimelineItemActionPostProcessor,
|
||||
@Assisted
|
||||
private val timelineMode: Timeline.Mode,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val room: BaseRoom,
|
||||
private val userSendFailureFactory: VerifiedUserSendFailureFactory,
|
||||
private val dateFormatter: DateFormatter,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val getRecentEmojis: GetRecentEmojis,
|
||||
) : ActionListPresenter {
|
||||
@AssistedFactory
|
||||
@ContributesBinding(RoomScope::class)
|
||||
interface Factory : ActionListPresenter.Factory {
|
||||
override fun create(
|
||||
postProcessor: TimelineItemActionPostProcessor,
|
||||
timelineMode: Timeline.Mode,
|
||||
): DefaultActionListPresenter
|
||||
}
|
||||
|
||||
private val comparator = TimelineItemActionComparator()
|
||||
|
||||
private val suggestedEmojis = persistentListOf("👍️", "👎️", "🔥", "❤️", "👏")
|
||||
|
||||
@Composable
|
||||
override fun present(): ActionListState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
|
||||
val target: MutableState<ActionListState.Target> = remember {
|
||||
mutableStateOf(ActionListState.Target.None)
|
||||
}
|
||||
|
||||
val isDeveloperModeEnabled by remember {
|
||||
appPreferencesStore.isDeveloperModeEnabledFlow()
|
||||
}.collectAsState(initial = false)
|
||||
val pinnedEventIds by remember {
|
||||
room.roomInfoFlow.map { it.pinnedEventIds }
|
||||
}.collectAsState(initial = persistentListOf())
|
||||
|
||||
val isThreadsEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false)
|
||||
|
||||
fun handleEvent(event: ActionListEvents) {
|
||||
when (event) {
|
||||
ActionListEvents.Clear -> target.value = ActionListState.Target.None
|
||||
is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(
|
||||
timelineItem = event.event,
|
||||
usersEventPermissions = event.userEventPermissions,
|
||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||
pinnedEventIds = pinnedEventIds,
|
||||
target = target,
|
||||
isThreadsEnabled = isThreadsEnabled.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return ActionListState(
|
||||
target = target.value,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.computeForMessage(
|
||||
timelineItem: TimelineItem.Event,
|
||||
usersEventPermissions: UserEventPermissions,
|
||||
isDeveloperModeEnabled: Boolean,
|
||||
pinnedEventIds: ImmutableList<EventId>,
|
||||
target: MutableState<ActionListState.Target>,
|
||||
isThreadsEnabled: Boolean,
|
||||
) = launch {
|
||||
target.value = ActionListState.Target.Loading(timelineItem)
|
||||
|
||||
val actions = buildActions(
|
||||
timelineItem = timelineItem,
|
||||
usersEventPermissions = usersEventPermissions,
|
||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||
isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
|
||||
isThreadsEnabled = isThreadsEnabled,
|
||||
)
|
||||
|
||||
val verifiedUserSendFailure = userSendFailureFactory.create(timelineItem.localSendState)
|
||||
val displayEmojiReactions = usersEventPermissions.canSendReaction && timelineItem.content.canReact()
|
||||
|
||||
if (actions.isNotEmpty() || displayEmojiReactions || verifiedUserSendFailure != VerifiedUserSendFailure.None) {
|
||||
val recentEmojis = getRecentEmojis().getOrNull()?.toImmutableList() ?: persistentListOf()
|
||||
target.value = ActionListState.Target.Success(
|
||||
event = timelineItem,
|
||||
sentTimeFull = dateFormatter.format(
|
||||
timelineItem.sentTimeMillis,
|
||||
DateFormatterMode.Full,
|
||||
useRelative = true,
|
||||
),
|
||||
displayEmojiReactions = displayEmojiReactions,
|
||||
verifiedUserSendFailure = verifiedUserSendFailure,
|
||||
actions = actions.toImmutableList(),
|
||||
// Merge suggested and recent emojis, removing duplicates and returning at most 100
|
||||
recentEmojis = (suggestedEmojis + recentEmojis).distinct()
|
||||
.take(100)
|
||||
.toImmutableList()
|
||||
)
|
||||
} else {
|
||||
target.value = ActionListState.Target.None
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildActions(
|
||||
timelineItem: TimelineItem.Event,
|
||||
usersEventPermissions: UserEventPermissions,
|
||||
isDeveloperModeEnabled: Boolean,
|
||||
isEventPinned: Boolean,
|
||||
isThreadsEnabled: Boolean,
|
||||
): List<TimelineItemAction> {
|
||||
val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther
|
||||
return buildSet {
|
||||
if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) {
|
||||
if (isThreadsEnabled && timelineMode !is Timeline.Mode.Thread && timelineItem.isRemote) {
|
||||
// If threads are enabled, we can reply in thread if the item is remote
|
||||
add(TimelineItemAction.ReplyInThread)
|
||||
add(TimelineItemAction.Reply)
|
||||
} else {
|
||||
if (!isThreadsEnabled && timelineItem.threadInfo is TimelineItemThreadInfo.ThreadResponse) {
|
||||
// If threads are not enabled, we can reply in a thread if the item is already in the thread
|
||||
add(TimelineItemAction.ReplyInThread)
|
||||
} else {
|
||||
// Otherwise, we can only reply in the room
|
||||
add(TimelineItemAction.Reply)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) {
|
||||
add(TimelineItemAction.Forward)
|
||||
}
|
||||
if (timelineItem.isEditable && usersEventPermissions.canSendMessage) {
|
||||
if (timelineItem.content is TimelineItemEventContentWithAttachment) {
|
||||
// Caption
|
||||
if (timelineItem.content.caption == null) {
|
||||
add(TimelineItemAction.AddCaption)
|
||||
} else {
|
||||
add(TimelineItemAction.EditCaption)
|
||||
add(TimelineItemAction.RemoveCaption)
|
||||
}
|
||||
} else if (timelineItem.content is TimelineItemPollContent) {
|
||||
add(TimelineItemAction.EditPoll)
|
||||
} else {
|
||||
add(TimelineItemAction.Edit)
|
||||
}
|
||||
}
|
||||
if (canRedact && timelineItem.content is TimelineItemPollContent && !timelineItem.content.isEnded) {
|
||||
add(TimelineItemAction.EndPoll)
|
||||
}
|
||||
val canPinUnpin = usersEventPermissions.canPinUnpin && timelineItem.isRemote
|
||||
if (canPinUnpin) {
|
||||
if (isEventPinned) {
|
||||
add(TimelineItemAction.Unpin)
|
||||
} else {
|
||||
add(TimelineItemAction.Pin)
|
||||
}
|
||||
}
|
||||
if (timelineItem.content.canBeCopied()) {
|
||||
add(TimelineItemAction.CopyText)
|
||||
} else if ((timelineItem.content as? TimelineItemEventContentWithAttachment)?.caption.isNullOrBlank().not()) {
|
||||
add(TimelineItemAction.CopyCaption)
|
||||
}
|
||||
if (timelineItem.isRemote) {
|
||||
add(TimelineItemAction.CopyLink)
|
||||
}
|
||||
if (isDeveloperModeEnabled) {
|
||||
add(TimelineItemAction.ViewSource)
|
||||
}
|
||||
if (!timelineItem.isMine) {
|
||||
add(TimelineItemAction.ReportContent)
|
||||
}
|
||||
if (canRedact) {
|
||||
add(TimelineItemAction.Redact)
|
||||
}
|
||||
}
|
||||
.postFilter(timelineItem.content)
|
||||
.sortedWith(comparator)
|
||||
.let(postProcessor::process)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post filter the actions based on the content of the event.
|
||||
*/
|
||||
private fun Iterable<TimelineItemAction>.postFilter(content: TimelineItemEventContent): Iterable<TimelineItemAction> {
|
||||
return filter { action ->
|
||||
when (content) {
|
||||
is TimelineItemRtcNotificationContent,
|
||||
is TimelineItemLegacyCallInviteContent,
|
||||
is TimelineItemStateContent -> action == TimelineItemAction.ViewSource
|
||||
is TimelineItemRedactedContent -> {
|
||||
action == TimelineItemAction.ViewSource || action == TimelineItemAction.Unpin
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2022-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.actionlist
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class ActionListState(
|
||||
val target: Target,
|
||||
val eventSink: (ActionListEvents) -> Unit,
|
||||
) {
|
||||
@Immutable
|
||||
sealed interface Target {
|
||||
data object None : Target
|
||||
data class Loading(val event: TimelineItem.Event) : Target
|
||||
data class Success(
|
||||
val event: TimelineItem.Event,
|
||||
val sentTimeFull: String,
|
||||
val displayEmojiReactions: Boolean,
|
||||
val recentEmojis: ImmutableList<String>,
|
||||
val verifiedUserSendFailure: VerifiedUserSendFailure,
|
||||
val actions: ImmutableList<TimelineItemAction>,
|
||||
) : Target
|
||||
}
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
/*
|
||||
* 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.actionlist
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionComparator
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.anUnsignedDeviceSendFailure
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemLocationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
||||
private val suggestedEmojis = persistentListOf("👍️", "👎️", "🔥", "❤️", "👏")
|
||||
|
||||
override val values: Sequence<ActionListState>
|
||||
get() {
|
||||
val reactionsState = aTimelineItemReactions(1, isHighlighted = true)
|
||||
return sequenceOf(
|
||||
anActionListState(),
|
||||
anActionListState().copy(target = ActionListState.Target.Loading(aTimelineItemEvent())),
|
||||
anActionListState(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(
|
||||
timelineItemReactions = reactionsState
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(),
|
||||
recentEmojis = suggestedEmojis,
|
||||
)
|
||||
),
|
||||
anActionListState(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(
|
||||
content = aTimelineItemImageContent(),
|
||||
displayNameAmbiguous = true,
|
||||
timelineItemReactions = reactionsState,
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(
|
||||
copyAction = TimelineItemAction.CopyCaption,
|
||||
),
|
||||
recentEmojis = suggestedEmojis,
|
||||
)
|
||||
),
|
||||
anActionListState(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(
|
||||
content = aTimelineItemVideoContent(),
|
||||
timelineItemReactions = reactionsState
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(
|
||||
copyAction = TimelineItemAction.CopyCaption,
|
||||
),
|
||||
recentEmojis = suggestedEmojis,
|
||||
)
|
||||
),
|
||||
anActionListState(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(
|
||||
content = aTimelineItemFileContent(),
|
||||
timelineItemReactions = reactionsState
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(
|
||||
copyAction = null,
|
||||
),
|
||||
recentEmojis = suggestedEmojis,
|
||||
)
|
||||
),
|
||||
anActionListState(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(
|
||||
content = aTimelineItemAudioContent(),
|
||||
timelineItemReactions = reactionsState
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(
|
||||
copyAction = TimelineItemAction.CopyCaption,
|
||||
),
|
||||
recentEmojis = suggestedEmojis,
|
||||
)
|
||||
),
|
||||
anActionListState(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(
|
||||
content = aTimelineItemVoiceContent(caption = null),
|
||||
timelineItemReactions = reactionsState
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(
|
||||
copyAction = null,
|
||||
),
|
||||
recentEmojis = suggestedEmojis,
|
||||
)
|
||||
),
|
||||
anActionListState(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(
|
||||
content = aTimelineItemLocationContent(),
|
||||
timelineItemReactions = reactionsState
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(),
|
||||
recentEmojis = suggestedEmojis,
|
||||
)
|
||||
),
|
||||
anActionListState(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(
|
||||
content = aTimelineItemLocationContent(),
|
||||
timelineItemReactions = reactionsState
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = false,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(),
|
||||
recentEmojis = suggestedEmojis,
|
||||
),
|
||||
),
|
||||
anActionListState(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(
|
||||
content = aTimelineItemPollContent(),
|
||||
timelineItemReactions = reactionsState
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = false,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemPollActionList(),
|
||||
recentEmojis = suggestedEmojis,
|
||||
),
|
||||
),
|
||||
anActionListState(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(
|
||||
timelineItemReactions = reactionsState,
|
||||
messageShield = MessageShield.UnknownDevice(isCritical = true)
|
||||
),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(),
|
||||
recentEmojis = suggestedEmojis,
|
||||
)
|
||||
),
|
||||
anActionListState(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(),
|
||||
sentTimeFull = "January 1, 1970 at 12:00 AM",
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = anUnsignedDeviceSendFailure(),
|
||||
actions = aTimelineItemActionList(),
|
||||
recentEmojis = suggestedEmojis,
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun anActionListState(
|
||||
target: ActionListState.Target = ActionListState.Target.None,
|
||||
eventSink: (ActionListEvents) -> Unit = {},
|
||||
) = ActionListState(
|
||||
target = target,
|
||||
eventSink = eventSink
|
||||
)
|
||||
|
||||
fun aTimelineItemActionList(
|
||||
copyAction: TimelineItemAction? = TimelineItemAction.CopyText
|
||||
): ImmutableList<TimelineItemAction> {
|
||||
return setOfNotNull(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
copyAction,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.Redact,
|
||||
TimelineItemAction.ReportContent,
|
||||
TimelineItemAction.ViewSource,
|
||||
)
|
||||
.sortedWith(TimelineItemActionComparator())
|
||||
.toImmutableList()
|
||||
}
|
||||
|
||||
fun aTimelineItemPollActionList(): ImmutableList<TimelineItemAction> {
|
||||
return setOf(
|
||||
TimelineItemAction.EndPoll,
|
||||
TimelineItemAction.EditPoll,
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
.sortedWith(TimelineItemActionComparator())
|
||||
.toImmutableList()
|
||||
}
|
||||
+508
@@ -0,0 +1,508 @@
|
||||
/*
|
||||
* 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.actionlist
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.traversalIndex
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
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.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.ChangedIdentity
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.None
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.UnsignedDevice
|
||||
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
|
||||
import io.element.android.features.messages.impl.timeline.components.MessageShieldView
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||
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.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.utils.messagesummary.DefaultMessageSummaryFormatter
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toSp
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.hide
|
||||
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.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ActionListView(
|
||||
state: ActionListState,
|
||||
onSelectAction: (action: TimelineItemAction, TimelineItem.Event) -> Unit,
|
||||
onEmojiReactionClick: (String, TimelineItem.Event) -> Unit,
|
||||
onCustomReactionClick: (TimelineItem.Event) -> Unit,
|
||||
onVerifiedUserSendFailureClick: (TimelineItem.Event) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val targetItem = (state.target as? ActionListState.Target.Success)?.event
|
||||
|
||||
fun onItemActionClick(
|
||||
itemAction: TimelineItemAction
|
||||
) {
|
||||
if (targetItem == null) return
|
||||
sheetState.hide(coroutineScope) {
|
||||
state.eventSink(ActionListEvents.Clear)
|
||||
onSelectAction(itemAction, targetItem)
|
||||
}
|
||||
}
|
||||
|
||||
fun onEmojiReactionClick(emoji: String) {
|
||||
if (targetItem == null) return
|
||||
sheetState.hide(coroutineScope) {
|
||||
state.eventSink(ActionListEvents.Clear)
|
||||
onEmojiReactionClick(emoji, targetItem)
|
||||
}
|
||||
}
|
||||
|
||||
fun onCustomReactionClick() {
|
||||
if (targetItem == null) return
|
||||
sheetState.hide(coroutineScope) {
|
||||
state.eventSink(ActionListEvents.Clear)
|
||||
onCustomReactionClick(targetItem)
|
||||
}
|
||||
}
|
||||
|
||||
fun onDismiss() {
|
||||
state.eventSink(ActionListEvents.Clear)
|
||||
}
|
||||
|
||||
fun onVerifiedUserSendFailureClick() {
|
||||
if (targetItem == null) return
|
||||
sheetState.hide(coroutineScope) {
|
||||
state.eventSink(ActionListEvents.Clear)
|
||||
onVerifiedUserSendFailureClick(targetItem)
|
||||
}
|
||||
}
|
||||
|
||||
if (targetItem != null) {
|
||||
ModalBottomSheet(
|
||||
sheetState = sheetState,
|
||||
onDismissRequest = ::onDismiss,
|
||||
modifier = modifier,
|
||||
) {
|
||||
ActionListViewContent(
|
||||
state = state,
|
||||
onActionClick = ::onItemActionClick,
|
||||
onEmojiReactionClick = ::onEmojiReactionClick,
|
||||
onCustomReactionClick = ::onCustomReactionClick,
|
||||
onVerifiedUserSendFailureClick = ::onVerifiedUserSendFailureClick,
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionListViewContent(
|
||||
state: ActionListState,
|
||||
onActionClick: (TimelineItemAction) -> Unit,
|
||||
onEmojiReactionClick: (String) -> Unit,
|
||||
onCustomReactionClick: () -> Unit,
|
||||
onVerifiedUserSendFailureClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (val target = state.target) {
|
||||
is ActionListState.Target.Loading,
|
||||
ActionListState.Target.None -> {
|
||||
// Crashes if sheetContent size is zero
|
||||
Box(modifier = modifier.size(1.dp))
|
||||
}
|
||||
|
||||
is ActionListState.Target.Success -> {
|
||||
val actions = target.actions
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
item {
|
||||
Column {
|
||||
MessageSummary(
|
||||
event = target.event,
|
||||
sentTimeFull = target.sentTimeFull,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.clearAndSetSemantics {},
|
||||
)
|
||||
if (target.event.messageShield != null) {
|
||||
MessageShieldView(
|
||||
shield = target.event.messageShield,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
if (target.verifiedUserSendFailure != None) {
|
||||
item {
|
||||
VerifiedUserSendFailureView(
|
||||
sendFailure = target.verifiedUserSendFailure,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onVerifiedUserSendFailureClick
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
if (target.displayEmojiReactions) {
|
||||
item {
|
||||
EmojiReactionsRow(
|
||||
recentEmojis = target.recentEmojis,
|
||||
highlightedEmojis = target.event.reactionsState.highlightedKeys,
|
||||
onEmojiReactionClick = onEmojiReactionClick,
|
||||
onCustomReactionClick = onCustomReactionClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
items(
|
||||
items = actions,
|
||||
) { action ->
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
onActionClick(action)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(text = stringResource(id = action.titleRes))
|
||||
},
|
||||
leadingContent = ListItemContent.Icon(IconSource.Resource(action.icon)),
|
||||
style = when {
|
||||
action.destructive -> ListItemStyle.Destructive
|
||||
else -> ListItemStyle.Primary
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MultipleEmitters") // False positive
|
||||
@Composable
|
||||
private fun MessageSummary(
|
||||
event: TimelineItem.Event,
|
||||
sentTimeFull: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val content: @Composable () -> Unit
|
||||
val icon: @Composable () -> Unit = {
|
||||
Avatar(
|
||||
avatarData = event.senderAvatar.copy(size = AvatarSize.MessageActionSender),
|
||||
avatarType = AvatarType.User,
|
||||
)
|
||||
}
|
||||
val contentStyle = ElementTheme.typography.fontBodyMdRegular.copy(color = ElementTheme.colors.textSecondary)
|
||||
|
||||
@Composable
|
||||
fun ContentForBody(body: String) {
|
||||
Text(body, style = contentStyle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val formatter = remember(context) { DefaultMessageSummaryFormatter(context) }
|
||||
val textContent = remember(event.content) { formatter.format(event) }
|
||||
|
||||
when (event.content) {
|
||||
is TimelineItemTextBasedContent,
|
||||
is TimelineItemStateContent,
|
||||
is TimelineItemEncryptedContent,
|
||||
is TimelineItemRedactedContent,
|
||||
is TimelineItemUnknownContent -> content = { ContentForBody(textContent) }
|
||||
is TimelineItemLocationContent -> {
|
||||
content = { ContentForBody(stringResource(CommonStrings.common_shared_location)) }
|
||||
}
|
||||
is TimelineItemImageContent -> {
|
||||
content = { ContentForBody(event.content.bestDescription) }
|
||||
}
|
||||
is TimelineItemStickerContent -> {
|
||||
content = { ContentForBody(event.content.bestDescription) }
|
||||
}
|
||||
is TimelineItemVideoContent -> {
|
||||
content = { ContentForBody(event.content.bestDescription) }
|
||||
}
|
||||
is TimelineItemFileContent -> {
|
||||
content = { ContentForBody(event.content.bestDescription) }
|
||||
}
|
||||
is TimelineItemAudioContent -> {
|
||||
content = { ContentForBody(event.content.bestDescription) }
|
||||
}
|
||||
is TimelineItemVoiceContent -> {
|
||||
content = { ContentForBody(textContent) }
|
||||
}
|
||||
is TimelineItemPollContent -> {
|
||||
content = { ContentForBody(textContent) }
|
||||
}
|
||||
is TimelineItemLegacyCallInviteContent -> {
|
||||
content = { ContentForBody(textContent) }
|
||||
}
|
||||
is TimelineItemRtcNotificationContent -> {
|
||||
content = { ContentForBody(stringResource(CommonStrings.common_call_started)) }
|
||||
}
|
||||
}
|
||||
Row(modifier = modifier) {
|
||||
icon()
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row {
|
||||
SenderName(
|
||||
modifier = Modifier.weight(1f),
|
||||
senderId = event.senderId,
|
||||
senderProfile = event.senderProfile,
|
||||
senderNameMode = SenderNameMode.ActionList,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(
|
||||
text = sentTimeFull,
|
||||
style = ElementTheme.typography.fontBodyXsRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val emojiRippleRadius = 24.dp
|
||||
|
||||
@Composable
|
||||
private fun EmojiReactionsRow(
|
||||
recentEmojis: ImmutableList<String>,
|
||||
highlightedEmojis: ImmutableList<String>,
|
||||
onEmojiReactionClick: (String) -> Unit,
|
||||
onCustomReactionClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.padding(end = 16.dp, top = 16.dp, bottom = 16.dp),
|
||||
) {
|
||||
val backgroundColor = ElementTheme.colors.bgCanvasDefault
|
||||
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.weight(1f, fill = true)
|
||||
.drawWithContent {
|
||||
val gradientWidth = 24.dp.toPx()
|
||||
val width = size.width
|
||||
drawContent()
|
||||
|
||||
drawRect(
|
||||
brush = Brush.horizontalGradient(
|
||||
0.0f to Color.Transparent,
|
||||
1.0f to backgroundColor,
|
||||
startX = width - gradientWidth,
|
||||
endX = width,
|
||||
),
|
||||
topLeft = Offset(width - gradientWidth, 0f),
|
||||
size = Size(gradientWidth, size.height)
|
||||
)
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(recentEmojis) { emoji ->
|
||||
val isHighlighted = highlightedEmojis.contains(emoji)
|
||||
EmojiButton(
|
||||
modifier = Modifier
|
||||
// Make it appear after the more useful actions for the accessibility service
|
||||
.semantics {
|
||||
traversalIndex = 1f
|
||||
},
|
||||
emoji = emoji,
|
||||
isHighlighted = isHighlighted,
|
||||
onClick = onEmojiReactionClick
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.padding(end = 10.dp).requiredSize(48.dp),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.ReactionAdd(),
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_react_with_other_emojis),
|
||||
tint = ElementTheme.colors.iconSecondary,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clickable(
|
||||
enabled = true,
|
||||
onClick = onCustomReactionClick,
|
||||
indication = ripple(bounded = false, radius = emojiRippleRadius),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
)
|
||||
// Make it appear after the more useful actions for the accessibility service
|
||||
.semantics {
|
||||
traversalIndex = 1f
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerifiedUserSendFailureView(
|
||||
sendFailure: VerifiedUserSendFailure,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
fun VerifiedUserSendFailure.headline(): String {
|
||||
return when (this) {
|
||||
is None -> ""
|
||||
is UnsignedDevice.FromOther -> stringResource(CommonStrings.screen_timeline_item_menu_send_failure_unsigned_device, userDisplayName)
|
||||
is UnsignedDevice.FromYou -> stringResource(CommonStrings.screen_timeline_item_menu_send_failure_you_unsigned_device)
|
||||
is ChangedIdentity -> stringResource(CommonStrings.screen_timeline_item_menu_send_failure_changed_identity, userDisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
ListItem(
|
||||
modifier = modifier
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ErrorSolid())),
|
||||
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChevronRight())),
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = sendFailure.headline(),
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
)
|
||||
},
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
leadingIconColor = ElementTheme.colors.iconCriticalPrimary,
|
||||
trailingIconColor = ElementTheme.colors.iconPrimary,
|
||||
headlineColor = ElementTheme.colors.textCriticalPrimary,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiButton(
|
||||
emoji: String,
|
||||
isHighlighted: Boolean,
|
||||
onClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val backgroundColor = if (isHighlighted) {
|
||||
ElementTheme.colors.bgActionPrimaryRest
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
val a11yClickLabel = a11yReactionAction(
|
||||
emoji = emoji,
|
||||
userAlreadyReacted = isHighlighted,
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(48.dp)
|
||||
.background(backgroundColor, CircleShape)
|
||||
.clickable(
|
||||
onClickLabel = a11yClickLabel,
|
||||
onClick = { onClick(emoji) },
|
||||
indication = ripple(bounded = false, radius = emojiRippleRadius),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
emoji,
|
||||
style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = 24.dp.toSp(), color = Color.White),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ActionListViewContentPreview(
|
||||
@PreviewParameter(ActionListStateProvider::class) state: ActionListState
|
||||
) = ElementPreview {
|
||||
ActionListViewContent(
|
||||
state = state,
|
||||
onActionClick = {},
|
||||
onEmojiReactionClick = {},
|
||||
onCustomReactionClick = {},
|
||||
onVerifiedUserSendFailureClick = {},
|
||||
)
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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.actionlist.model
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
enum class TimelineItemAction(
|
||||
@StringRes val titleRes: Int,
|
||||
@DrawableRes val icon: Int,
|
||||
val destructive: Boolean = false
|
||||
) {
|
||||
ViewInTimeline(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on),
|
||||
Forward(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward),
|
||||
CopyText(CommonStrings.action_copy_text, CompoundDrawables.ic_compound_copy),
|
||||
CopyCaption(CommonStrings.action_copy_caption, CompoundDrawables.ic_compound_copy),
|
||||
CopyLink(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link),
|
||||
Redact(CommonStrings.action_remove, CompoundDrawables.ic_compound_delete, destructive = true),
|
||||
Reply(CommonStrings.action_reply, CompoundDrawables.ic_compound_reply),
|
||||
ReplyInThread(CommonStrings.action_reply_in_thread, CompoundDrawables.ic_compound_reply),
|
||||
Edit(CommonStrings.action_edit, CompoundDrawables.ic_compound_edit),
|
||||
EditPoll(CommonStrings.action_edit_poll, CompoundDrawables.ic_compound_edit),
|
||||
EditCaption(CommonStrings.action_edit_caption, CompoundDrawables.ic_compound_edit),
|
||||
AddCaption(CommonStrings.action_add_caption, CompoundDrawables.ic_compound_edit),
|
||||
RemoveCaption(CommonStrings.action_remove_caption, CompoundDrawables.ic_compound_close, destructive = true),
|
||||
ViewSource(CommonStrings.action_view_source, CompoundDrawables.ic_compound_code),
|
||||
ReportContent(CommonStrings.action_report_content, CompoundDrawables.ic_compound_chat_problem, destructive = true),
|
||||
EndPoll(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end),
|
||||
Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin),
|
||||
Unpin(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin),
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.actionlist.model
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
|
||||
class TimelineItemActionComparator : Comparator<TimelineItemAction> {
|
||||
// See order in https://www.figma.com/design/ux3tYoZV9WghC7hHT9Fhk0/Compound-iOS-Components?node-id=2946-2392
|
||||
@VisibleForTesting
|
||||
val orderedList = listOf(
|
||||
TimelineItemAction.EndPoll,
|
||||
TimelineItemAction.ViewInTimeline,
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.ReplyInThread,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.EditPoll,
|
||||
TimelineItemAction.AddCaption,
|
||||
TimelineItemAction.EditCaption,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.Unpin,
|
||||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.CopyCaption,
|
||||
TimelineItemAction.RemoveCaption,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
|
||||
override fun compare(o1: TimelineItemAction, o2: TimelineItemAction): Int {
|
||||
val index1 = orderedList.indexOf(o1)
|
||||
val index2 = orderedList.indexOf(o2)
|
||||
return index1.compareTo(index2)
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.actionlist.model
|
||||
|
||||
fun interface TimelineItemActionPostProcessor {
|
||||
fun process(actions: List<TimelineItemAction>): List<TimelineItemAction>
|
||||
|
||||
object Default : TimelineItemActionPostProcessor {
|
||||
override fun process(actions: List<TimelineItemAction>): List<TimelineItemAction> {
|
||||
return actions
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.attachments
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Immutable
|
||||
sealed interface Attachment : Parcelable {
|
||||
@Parcelize
|
||||
data class Media(val localMedia: LocalMedia) : Attachment
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* 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.attachments.preview
|
||||
|
||||
sealed interface AttachmentsPreviewEvents {
|
||||
data object SendAttachment : AttachmentsPreviewEvents
|
||||
data object CancelAndDismiss : AttachmentsPreviewEvents
|
||||
data object CancelAndClearSendState : AttachmentsPreviewEvents
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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.attachments.preview
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.compound.colors.SemanticColorsLightDark
|
||||
import io.element.android.compound.theme.ForcedDarkElementTheme
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
@AssistedInject
|
||||
class AttachmentsPreviewNode(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: AttachmentsPreviewPresenter.Factory,
|
||||
private val localMediaRenderer: LocalMediaRenderer,
|
||||
private val sessionId: SessionId,
|
||||
private val enterpriseService: EnterpriseService,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
data class Inputs(
|
||||
val attachment: Attachment,
|
||||
val timelineMode: Timeline.Mode,
|
||||
val inReplyToEventId: EventId?,
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs: Inputs = inputs()
|
||||
|
||||
private val onDoneListener = OnDoneListener {
|
||||
navigateUp()
|
||||
}
|
||||
|
||||
private val presenter = presenterFactory.create(
|
||||
attachment = inputs.attachment,
|
||||
timelineMode = inputs.timelineMode,
|
||||
onDoneListener = onDoneListener,
|
||||
inReplyToEventId = inputs.inReplyToEventId,
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val colors by remember {
|
||||
enterpriseService.semanticColorsFlow(sessionId = sessionId)
|
||||
}.collectAsState(SemanticColorsLightDark.default)
|
||||
ForcedDarkElementTheme(
|
||||
colors = colors,
|
||||
) {
|
||||
val state = presenter.present()
|
||||
AttachmentsPreviewView(
|
||||
state = state,
|
||||
localMediaRenderer = localMediaRenderer,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
/*
|
||||
* 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.attachments.preview
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter
|
||||
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||
import io.element.android.libraries.androidutils.file.safeDelete
|
||||
import io.element.android.libraries.androidutils.hash.hash
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.coroutine.firstInstanceOf
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
|
||||
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
|
||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||
import io.element.android.libraries.mediaupload.api.allFiles
|
||||
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
@AssistedInject
|
||||
class AttachmentsPreviewPresenter(
|
||||
@Assisted private val attachment: Attachment,
|
||||
@Assisted private val onDoneListener: OnDoneListener,
|
||||
@Assisted private val timelineMode: Timeline.Mode,
|
||||
@Assisted private val inReplyToEventId: EventId?,
|
||||
mediaSenderFactory: MediaSenderFactory,
|
||||
private val permalinkBuilder: PermalinkBuilder,
|
||||
private val temporaryUriDeleter: TemporaryUriDeleter,
|
||||
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
|
||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : Presenter<AttachmentsPreviewState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
attachment: Attachment,
|
||||
timelineMode: Timeline.Mode,
|
||||
onDoneListener: OnDoneListener,
|
||||
inReplyToEventId: EventId?,
|
||||
): AttachmentsPreviewPresenter
|
||||
}
|
||||
|
||||
private val mediaSender = mediaSenderFactory.create(timelineMode)
|
||||
|
||||
@Composable
|
||||
override fun present(): AttachmentsPreviewState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val sendActionState = remember {
|
||||
mutableStateOf<SendActionState>(SendActionState.Idle)
|
||||
}
|
||||
|
||||
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
|
||||
val textEditorState by rememberUpdatedState(
|
||||
TextEditorState.Markdown(markdownTextEditorState, isRoomEncrypted = null)
|
||||
)
|
||||
|
||||
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
val mediaAttachment = attachment as Attachment.Media
|
||||
val mediaOptimizationSelectorPresenter = remember {
|
||||
mediaOptimizationSelectorPresenterFactory.create(mediaAttachment.localMedia)
|
||||
}
|
||||
val mediaOptimizationSelectorState = mediaOptimizationSelectorPresenter.present()
|
||||
|
||||
val observableSendState = snapshotFlow { sendActionState.value }
|
||||
|
||||
var displayFileTooLargeError by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(mediaOptimizationSelectorState.displayMediaSelectorViews) {
|
||||
// If the media optimization selector is not displayed, we can pre-process the media
|
||||
// to prepare it for sending. This is done to avoid blocking the UI thread when the
|
||||
// user clicks on the send button.
|
||||
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false) {
|
||||
val mediaOptimizationConfig = MediaOptimizationConfig(
|
||||
compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true,
|
||||
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD,
|
||||
)
|
||||
preprocessMediaJob = preProcessAttachment(
|
||||
attachment = attachment,
|
||||
mediaOptimizationConfig = mediaOptimizationConfig,
|
||||
displayProgress = false,
|
||||
sendActionState = sendActionState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val maxUploadSize = mediaOptimizationSelectorState.maxUploadSize.dataOrNull()
|
||||
LaunchedEffect(maxUploadSize) {
|
||||
// Check file upload size if the media won't be processed for upload
|
||||
val isImageFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeImage()
|
||||
val isVideoFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo()
|
||||
if (maxUploadSize != null && !(isImageFile || isVideoFile)) {
|
||||
// If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed.
|
||||
val fileSize = mediaAttachment.localMedia.info.fileSize ?: 0L
|
||||
if (maxUploadSize < fileSize) {
|
||||
displayFileTooLargeError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull()
|
||||
LaunchedEffect(videoSizeEstimations) {
|
||||
if (videoSizeEstimations != null) {
|
||||
// Check if the video size estimations are too large for the max upload size
|
||||
displayFileTooLargeError = videoSizeEstimations.none { it.canUpload }
|
||||
}
|
||||
}
|
||||
|
||||
fun handleEvent(event: AttachmentsPreviewEvents) {
|
||||
when (event) {
|
||||
is AttachmentsPreviewEvents.SendAttachment -> {
|
||||
ongoingSendAttachmentJob.value = coroutineScope.launch {
|
||||
// If the media optimization selector is displayed, we need to wait for the user to select the options
|
||||
// before we can pre-process the media.
|
||||
if (mediaOptimizationSelectorState.displayMediaSelectorViews == true) {
|
||||
val config = MediaOptimizationConfig(
|
||||
compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true,
|
||||
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD,
|
||||
)
|
||||
preprocessMediaJob = preProcessAttachment(
|
||||
attachment = attachment,
|
||||
mediaOptimizationConfig = config,
|
||||
displayProgress = true,
|
||||
sendActionState = sendActionState,
|
||||
)
|
||||
}
|
||||
|
||||
// If the processing was hidden before, make it visible now
|
||||
if (sendActionState.value is SendActionState.Sending.Processing) {
|
||||
sendActionState.value = SendActionState.Sending.Processing(displayProgress = true)
|
||||
}
|
||||
|
||||
// Wait until the media is ready to be uploaded
|
||||
val mediaUploadInfo = observableSendState.firstInstanceOf<SendActionState.Sending.ReadyToUpload>().mediaInfo
|
||||
|
||||
// Pre-processing is done, send the attachment
|
||||
val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
|
||||
.takeIf { it.isNotEmpty() }
|
||||
|
||||
// If we're supposed to send the media as a background job, we can dismiss this screen already
|
||||
if (coroutineContext.isActive) {
|
||||
onDoneListener()
|
||||
}
|
||||
|
||||
// Send the media using the session coroutine scope so it doesn't matter if this screen or the chat one are closed
|
||||
sessionCoroutineScope.launch(dispatchers.io) {
|
||||
sendPreProcessedMedia(
|
||||
mediaUploadInfo = mediaUploadInfo,
|
||||
caption = caption,
|
||||
sendActionState = sendActionState,
|
||||
dismissAfterSend = false,
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
|
||||
// Clean up the pre-processed media after it's been sent
|
||||
mediaSender.cleanUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
AttachmentsPreviewEvents.CancelAndDismiss -> {
|
||||
displayFileTooLargeError = false
|
||||
|
||||
// Cancel media preprocessing and sending
|
||||
preprocessMediaJob?.cancel()
|
||||
// If we couldn't send the pre-processed media, remove it
|
||||
mediaSender.cleanUp()
|
||||
ongoingSendAttachmentJob.value?.cancel()
|
||||
|
||||
// Dismiss the screen
|
||||
dismiss(
|
||||
attachment,
|
||||
sendActionState,
|
||||
)
|
||||
}
|
||||
AttachmentsPreviewEvents.CancelAndClearSendState -> {
|
||||
// Cancel media sending
|
||||
ongoingSendAttachmentJob.value?.let {
|
||||
it.cancel()
|
||||
ongoingSendAttachmentJob.value = null
|
||||
}
|
||||
|
||||
val mediaUploadInfo = sendActionState.value.mediaUploadInfo()
|
||||
sendActionState.value = if (mediaUploadInfo != null) {
|
||||
SendActionState.Sending.ReadyToUpload(mediaUploadInfo)
|
||||
} else {
|
||||
SendActionState.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AttachmentsPreviewState(
|
||||
attachment = attachment,
|
||||
sendActionState = sendActionState.value,
|
||||
textEditorState = textEditorState,
|
||||
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
|
||||
displayFileTooLargeError = displayFileTooLargeError,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.preProcessAttachment(
|
||||
attachment: Attachment,
|
||||
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||
displayProgress: Boolean,
|
||||
sendActionState: MutableState<SendActionState>,
|
||||
) = launch(dispatchers.io) {
|
||||
when (attachment) {
|
||||
is Attachment.Media -> {
|
||||
preProcessMedia(
|
||||
mediaAttachment = attachment,
|
||||
mediaOptimizationConfig = mediaOptimizationConfig,
|
||||
displayProgress = displayProgress,
|
||||
sendActionState = sendActionState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun preProcessMedia(
|
||||
mediaAttachment: Attachment.Media,
|
||||
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||
displayProgress: Boolean,
|
||||
sendActionState: MutableState<SendActionState>,
|
||||
) {
|
||||
sendActionState.value = SendActionState.Sending.Processing(displayProgress = displayProgress)
|
||||
mediaSender.preProcessMedia(
|
||||
uri = mediaAttachment.localMedia.uri,
|
||||
mimeType = mediaAttachment.localMedia.info.mimeType,
|
||||
mediaOptimizationConfig = mediaOptimizationConfig,
|
||||
).fold(
|
||||
onSuccess = { mediaUploadInfo ->
|
||||
Timber.d("Media ${mediaUploadInfo.file.path.orEmpty().hash()} finished processing, it's now ready to upload")
|
||||
sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfo)
|
||||
},
|
||||
onFailure = {
|
||||
Timber.e(it, "Failed to pre-process media")
|
||||
if (it is CancellationException) {
|
||||
throw it
|
||||
} else {
|
||||
sendActionState.value = SendActionState.Failure(it, null)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun dismiss(
|
||||
attachment: Attachment,
|
||||
sendActionState: MutableState<SendActionState>,
|
||||
) {
|
||||
// Delete the temporary file
|
||||
when (attachment) {
|
||||
is Attachment.Media -> {
|
||||
temporaryUriDeleter.delete(attachment.localMedia.uri)
|
||||
sendActionState.value.mediaUploadInfo()?.let { data ->
|
||||
cleanUp(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reset the sendActionState to ensure that dialog is closed before the screen
|
||||
sendActionState.value = SendActionState.Done
|
||||
onDoneListener()
|
||||
}
|
||||
|
||||
private fun cleanUp(
|
||||
mediaUploadInfo: MediaUploadInfo,
|
||||
) {
|
||||
mediaUploadInfo.allFiles().forEach { file ->
|
||||
file.safeDelete()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendPreProcessedMedia(
|
||||
mediaUploadInfo: MediaUploadInfo,
|
||||
caption: String?,
|
||||
sendActionState: MutableState<SendActionState>,
|
||||
dismissAfterSend: Boolean,
|
||||
inReplyToEventId: EventId?,
|
||||
) = runCatchingExceptions {
|
||||
sendActionState.value = SendActionState.Sending.Uploading(mediaUploadInfo)
|
||||
mediaSender.sendPreProcessedMedia(
|
||||
mediaUploadInfo = mediaUploadInfo,
|
||||
caption = caption,
|
||||
formattedCaption = null,
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
).getOrThrow()
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
cleanUp(mediaUploadInfo)
|
||||
// Reset the sendActionState to ensure that dialog is closed before the screen
|
||||
sendActionState.value = SendActionState.Done
|
||||
|
||||
if (dismissAfterSend) {
|
||||
onDoneListener()
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
Timber.e(error, "Failed to send attachment")
|
||||
if (error is CancellationException) {
|
||||
throw error
|
||||
} else {
|
||||
sendActionState.value = SendActionState.Failure(error, mediaUploadInfo)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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.attachments.preview
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
|
||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
|
||||
data class AttachmentsPreviewState(
|
||||
val attachment: Attachment,
|
||||
val sendActionState: SendActionState,
|
||||
val textEditorState: TextEditorState,
|
||||
val mediaOptimizationSelectorState: MediaOptimizationSelectorState,
|
||||
val displayFileTooLargeError: Boolean,
|
||||
val eventSink: (AttachmentsPreviewEvents) -> Unit
|
||||
)
|
||||
|
||||
@Immutable
|
||||
sealed interface SendActionState {
|
||||
data object Idle : SendActionState
|
||||
|
||||
@Immutable
|
||||
sealed interface Sending : SendActionState {
|
||||
data class Processing(val displayProgress: Boolean) : Sending
|
||||
data class ReadyToUpload(val mediaInfo: MediaUploadInfo) : Sending
|
||||
data class Uploading(val mediaUploadInfo: MediaUploadInfo) : Sending
|
||||
}
|
||||
|
||||
data class Failure(val error: Throwable, val mediaUploadInfo: MediaUploadInfo?) : SendActionState
|
||||
data object Done : SendActionState
|
||||
|
||||
fun mediaUploadInfo(): MediaUploadInfo? = when (this) {
|
||||
is Sending.ReadyToUpload -> mediaInfo
|
||||
is Sending.Uploading -> mediaUploadInfo
|
||||
is Failure -> mediaUploadInfo
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* 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.attachments.preview
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.core.net.toUri
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
|
||||
import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import java.io.File
|
||||
|
||||
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
|
||||
override val values: Sequence<AttachmentsPreviewState>
|
||||
get() = sequenceOf(
|
||||
anAttachmentsPreviewState(),
|
||||
anAttachmentsPreviewState(
|
||||
sendActionState = SendActionState.Sending.Processing(displayProgress = false),
|
||||
textEditorState = aTextEditorStateMarkdown(
|
||||
initialText = "This is a caption!"
|
||||
)
|
||||
),
|
||||
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing(displayProgress = true)),
|
||||
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.ReadyToUpload(aMediaUploadInfo())),
|
||||
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(aMediaUploadInfo())),
|
||||
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())),
|
||||
anAttachmentsPreviewState(displayFileTooLargeError = true),
|
||||
anAttachmentsPreviewState(
|
||||
mediaInfo = aVideoMediaInfo(),
|
||||
mediaOptimizationSelectorState = aMediaOptimisationSelectorState(
|
||||
selectedVideoPreset = VideoCompressionPreset.STANDARD,
|
||||
videoSizeEstimations = aVideoSizeEstimationList(),
|
||||
)
|
||||
),
|
||||
anAttachmentsPreviewState(
|
||||
mediaInfo = aVideoMediaInfo(),
|
||||
mediaOptimizationSelectorState = aMediaOptimisationSelectorState(
|
||||
videoSizeEstimations = aVideoSizeEstimationList(),
|
||||
displayVideoPresetSelectorDialog = true,
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun anAttachmentsPreviewState(
|
||||
mediaInfo: MediaInfo = anImageMediaInfo(),
|
||||
textEditorState: TextEditorState = aTextEditorStateMarkdown(),
|
||||
sendActionState: SendActionState = SendActionState.Idle,
|
||||
mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(),
|
||||
displayFileTooLargeError: Boolean = false,
|
||||
) = AttachmentsPreviewState(
|
||||
attachment = Attachment.Media(
|
||||
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
|
||||
),
|
||||
sendActionState = sendActionState,
|
||||
textEditorState = textEditorState,
|
||||
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
|
||||
displayFileTooLargeError = displayFileTooLargeError,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
fun aMediaUploadInfo(
|
||||
filePath: String = "file://path",
|
||||
thumbnailFilePath: String? = null,
|
||||
) = MediaUploadInfo.Image(
|
||||
file = File(filePath),
|
||||
imageInfo = ImageInfo(
|
||||
height = 100,
|
||||
width = 100,
|
||||
mimetype = MimeTypes.Jpeg,
|
||||
size = 1000,
|
||||
thumbnailInfo = null,
|
||||
thumbnailSource = null,
|
||||
blurhash = null,
|
||||
),
|
||||
thumbnailFile = thumbnailFilePath?.let { File(it) },
|
||||
)
|
||||
|
||||
fun aMediaOptimisationSelectorState(
|
||||
maxUploadSize: Long = 100,
|
||||
videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>> = AsyncData.Success(persistentListOf()),
|
||||
isImageOptimizationEnabled: Boolean = true,
|
||||
selectedVideoPreset: VideoCompressionPreset = VideoCompressionPreset.STANDARD,
|
||||
displayMediaSelectorViews: Boolean = true,
|
||||
displayVideoPresetSelectorDialog: Boolean = false,
|
||||
) = MediaOptimizationSelectorState(
|
||||
maxUploadSize = AsyncData.Success(maxUploadSize),
|
||||
videoSizeEstimations = videoSizeEstimations,
|
||||
isImageOptimizationEnabled = isImageOptimizationEnabled,
|
||||
selectedVideoPreset = selectedVideoPreset,
|
||||
displayMediaSelectorViews = displayMediaSelectorViews,
|
||||
displayVideoPresetSelectorDialog = displayVideoPresetSelectorDialog,
|
||||
eventSink = {},
|
||||
)
|
||||
|
||||
internal fun aVideoSizeEstimationList(): AsyncData<ImmutableList<VideoUploadEstimation>> = AsyncData.Success(
|
||||
persistentListOf(
|
||||
VideoUploadEstimation(
|
||||
preset = VideoCompressionPreset.HIGH,
|
||||
sizeInBytes = 8_200_000L,
|
||||
canUpload = false,
|
||||
),
|
||||
VideoUploadEstimation(
|
||||
preset = VideoCompressionPreset.STANDARD,
|
||||
sizeInBytes = 4_200_000L,
|
||||
canUpload = true,
|
||||
),
|
||||
)
|
||||
)
|
||||
+444
@@ -0,0 +1,444 @@
|
||||
/*
|
||||
* 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.attachments.preview
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
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.R
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
||||
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorEvent
|
||||
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
|
||||
import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialogType
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.modifiers.niceClickable
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Switch
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
|
||||
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||
import io.element.android.libraries.textcomposer.TextComposer
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.ui.utils.formatter.rememberFileSizeFormatter
|
||||
import io.element.android.wysiwyg.display.TextDisplay
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AttachmentsPreviewView(
|
||||
state: AttachmentsPreviewState,
|
||||
localMediaRenderer: LocalMediaRenderer,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun postSendAttachment() {
|
||||
state.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
||||
}
|
||||
|
||||
fun postCancel() {
|
||||
state.eventSink(AttachmentsPreviewEvents.CancelAndDismiss)
|
||||
}
|
||||
|
||||
fun postClearSendState() {
|
||||
state.eventSink(AttachmentsPreviewEvents.CancelAndClearSendState)
|
||||
}
|
||||
|
||||
BackHandler(enabled = state.sendActionState !is SendActionState.Sending.Uploading && state.sendActionState !is SendActionState.Done) {
|
||||
postCancel()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
BackButton(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
onClick = ::postCancel,
|
||||
)
|
||||
},
|
||||
title = {},
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
AttachmentPreviewContent(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
state = state,
|
||||
localMediaRenderer = localMediaRenderer,
|
||||
onSendClick = ::postSendAttachment,
|
||||
)
|
||||
}
|
||||
AttachmentSendStateView(
|
||||
sendActionState = state.sendActionState,
|
||||
onDismissClick = ::postClearSendState,
|
||||
onRetryClick = ::postSendAttachment
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentSendStateView(
|
||||
sendActionState: SendActionState,
|
||||
onDismissClick: () -> Unit,
|
||||
onRetryClick: () -> Unit
|
||||
) {
|
||||
when (sendActionState) {
|
||||
is SendActionState.Sending.Processing -> {
|
||||
if (sendActionState.displayProgress) {
|
||||
ProgressDialog(
|
||||
type = ProgressDialogType.Indeterminate,
|
||||
text = stringResource(CommonStrings.common_preparing),
|
||||
showCancelButton = true,
|
||||
onDismissRequest = onDismissClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
is SendActionState.Sending.Uploading -> {
|
||||
ProgressDialog(
|
||||
type = ProgressDialogType.Indeterminate,
|
||||
text = stringResource(id = CommonStrings.common_sending),
|
||||
showCancelButton = true,
|
||||
onDismissRequest = onDismissClick,
|
||||
)
|
||||
}
|
||||
is SendActionState.Failure -> {
|
||||
RetryDialog(
|
||||
content = stringResource(sendAttachmentError(sendActionState.error)),
|
||||
onDismiss = onDismissClick,
|
||||
onRetry = onRetryClick
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentPreviewContent(
|
||||
state: AttachmentsPreviewState,
|
||||
localMediaRenderer: LocalMediaRenderer,
|
||||
onSendClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (val attachment = state.attachment) {
|
||||
is Attachment.Media -> {
|
||||
localMediaRenderer.Render(attachment.localMedia)
|
||||
}
|
||||
}
|
||||
}
|
||||
val mimeType = (state.attachment as? Attachment.Media)?.localMedia?.info?.mimeType
|
||||
if (mimeType?.isMimeTypeImage() == true) {
|
||||
ImageOptimizationSelector(state.mediaOptimizationSelectorState)
|
||||
} else if (mimeType?.isMimeTypeVideo() == true) {
|
||||
VideoPresetSelector(state = state.mediaOptimizationSelectorState)
|
||||
}
|
||||
|
||||
val sizeFormatter = rememberFileSizeFormatter()
|
||||
if (state.displayFileTooLargeError) {
|
||||
val maxFileUploadSize = state.mediaOptimizationSelectorState.maxUploadSize.dataOrNull()
|
||||
if (maxFileUploadSize != null) {
|
||||
val content = stringResource(CommonStrings.dialog_file_too_large_to_upload_subtitle, sizeFormatter.format(maxFileUploadSize, true))
|
||||
AlertDialog(
|
||||
title = stringResource(CommonStrings.dialog_file_too_large_to_upload_title),
|
||||
content = content,
|
||||
onDismiss = { state.eventSink(AttachmentsPreviewEvents.CancelAndDismiss) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AttachmentsPreviewBottomActions(
|
||||
state = state,
|
||||
onSendClick = onSendClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
.height(IntrinsicSize.Min)
|
||||
.imePadding(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) {
|
||||
if (state.displayMediaSelectorViews == true) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.niceClickable {
|
||||
state.isImageOptimizationEnabled?.let { value ->
|
||||
state.eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(!value))
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.weight(1f).align(Alignment.CenterVertically),
|
||||
text = stringResource(R.string.screen_media_upload_preview_optimize_image_quality_title),
|
||||
style = ElementTheme.materialTypography.bodyLarge,
|
||||
)
|
||||
Switch(
|
||||
modifier = Modifier.height(32.dp),
|
||||
checked = state.isImageOptimizationEnabled.orFalse(),
|
||||
onCheckedChange = { value -> state.eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(value)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoPresetSelector(
|
||||
state: MediaOptimizationSelectorState,
|
||||
) {
|
||||
val videoPresets = state.videoSizeEstimations.dataOrNull()
|
||||
var selectedPreset by remember(state.selectedVideoPreset) { mutableStateOf(state.selectedVideoPreset) }
|
||||
|
||||
val displayDialog = state.displayVideoPresetSelectorDialog
|
||||
|
||||
val sizeFormatter = rememberFileSizeFormatter()
|
||||
|
||||
if (state.displayMediaSelectorViews == true && videoPresets != null && state.selectedVideoPreset != null) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
.niceClickable { state.eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog) }
|
||||
) {
|
||||
val estimation = videoPresets.find { it.preset == selectedPreset }
|
||||
val estimationMb = estimation?.sizeInBytes?.let { sizeFormatter.format(it, true) }
|
||||
val title = buildString {
|
||||
append(state.selectedVideoPreset.title())
|
||||
if (estimationMb != null) {
|
||||
append(" ($estimationMb)")
|
||||
}
|
||||
}
|
||||
Text(text = title, style = ElementTheme.typography.fontBodyLgMedium)
|
||||
Text(
|
||||
text = stringResource(R.string.screen_media_upload_preview_change_video_quality_prompt),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (displayDialog) {
|
||||
VideoQualitySelectorDialog(
|
||||
selectedPreset = selectedPreset ?: VideoCompressionPreset.STANDARD,
|
||||
videoSizeEstimations = videoPresets ?: persistentListOf(),
|
||||
maxFileUploadSize = state.maxUploadSize.dataOrNull(),
|
||||
onSubmit = { preset ->
|
||||
selectedPreset = preset
|
||||
state.eventSink(MediaOptimizationSelectorEvent.SelectVideoPreset(preset))
|
||||
},
|
||||
onDismiss = { state.eventSink(MediaOptimizationSelectorEvent.DismissVideoPresetSelectorDialog) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoQualitySelectorDialog(
|
||||
selectedPreset: VideoCompressionPreset,
|
||||
videoSizeEstimations: ImmutableList<VideoUploadEstimation>,
|
||||
maxFileUploadSize: Long?,
|
||||
onSubmit: (VideoCompressionPreset) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sizeFormatter = rememberFileSizeFormatter()
|
||||
|
||||
var localSelectedPreset by remember(selectedPreset) { mutableStateOf(selectedPreset) }
|
||||
val subtitlePartNoFileSize = stringResource(CommonStrings.dialog_video_quality_selector_subtitle_no_file_size)
|
||||
val subtitlePartWithFileSize = stringResource(CommonStrings.dialog_video_quality_selector_subtitle_file_size)
|
||||
val subtitle = remember(maxFileUploadSize) {
|
||||
buildString {
|
||||
append(subtitlePartNoFileSize)
|
||||
if (maxFileUploadSize != null) {
|
||||
append(String.format(subtitlePartWithFileSize, sizeFormatter.format(maxFileUploadSize, true)))
|
||||
}
|
||||
}
|
||||
}
|
||||
ListDialog(
|
||||
title = stringResource(CommonStrings.dialog_video_quality_selector_title),
|
||||
subtitle = subtitle,
|
||||
onSubmit = { onSubmit(localSelectedPreset) },
|
||||
onDismissRequest = onDismiss,
|
||||
applyPaddingToContents = false,
|
||||
) {
|
||||
for (videoEstimation in videoSizeEstimations) {
|
||||
val preset = videoEstimation.preset
|
||||
val isSelected = preset == localSelectedPreset
|
||||
item(
|
||||
key = preset,
|
||||
contentType = preset,
|
||||
) {
|
||||
val estimationMb = sizeFormatter.format(videoEstimation.sizeInBytes, true)
|
||||
val title = "${preset.title()} ($estimationMb)"
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = title,
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = preset.subtitle(),
|
||||
style = ElementTheme.materialTypography.bodyMedium,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
},
|
||||
leadingContent = ListItemContent.RadioButton(
|
||||
selected = isSelected,
|
||||
),
|
||||
onClick = {
|
||||
localSelectedPreset = preset
|
||||
},
|
||||
enabled = videoEstimation.canUpload,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentsPreviewBottomActions(
|
||||
state: AttachmentsPreviewState,
|
||||
onSendClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TextComposer(
|
||||
modifier = modifier,
|
||||
state = state.textEditorState,
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
composerMode = MessageComposerMode.Attachment,
|
||||
onRequestFocus = {},
|
||||
onSendMessage = onSendClick,
|
||||
showTextFormatting = false,
|
||||
onResetComposerMode = {},
|
||||
onAddAttachment = {},
|
||||
onDismissTextFormatting = {},
|
||||
onVoiceRecorderEvent = {},
|
||||
onVoicePlayerEvent = {},
|
||||
onSendVoiceMessage = {},
|
||||
onDeleteVoiceMessage = {},
|
||||
onReceiveSuggestion = {},
|
||||
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
|
||||
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
|
||||
onError = {},
|
||||
onTyping = {},
|
||||
onSelectRichContent = {},
|
||||
)
|
||||
}
|
||||
|
||||
// Only preview in dark, dark theme is forced on the Node.
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
|
||||
AttachmentsPreviewView(
|
||||
state = state,
|
||||
localMediaRenderer = object : LocalMediaRenderer {
|
||||
@Composable
|
||||
override fun Render(localMedia: LocalMedia) {
|
||||
Image(
|
||||
painter = painterResource(id = CommonDrawables.sample_background),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun VideoQualitySelectorDialogPreview() {
|
||||
ElementPreview {
|
||||
VideoQualitySelectorDialog(
|
||||
selectedPreset = VideoCompressionPreset.STANDARD,
|
||||
videoSizeEstimations = persistentListOf(
|
||||
VideoUploadEstimation(VideoCompressionPreset.HIGH, 2_000_000, canUpload = false),
|
||||
VideoUploadEstimation(VideoCompressionPreset.STANDARD, 1_000_000, canUpload = true),
|
||||
VideoUploadEstimation(VideoCompressionPreset.LOW, 500_000, canUpload = true)
|
||||
),
|
||||
maxFileUploadSize = 1_500_000,
|
||||
onSubmit = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoCompressionPreset.title(): String {
|
||||
return stringResource(
|
||||
when (this) {
|
||||
VideoCompressionPreset.STANDARD -> CommonStrings.common_video_quality_standard
|
||||
VideoCompressionPreset.HIGH -> CommonStrings.common_video_quality_high
|
||||
VideoCompressionPreset.LOW -> CommonStrings.common_video_quality_low
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoCompressionPreset.subtitle(): String {
|
||||
return stringResource(
|
||||
when (this) {
|
||||
VideoCompressionPreset.STANDARD -> CommonStrings.common_video_quality_standard_description
|
||||
VideoCompressionPreset.HIGH -> CommonStrings.common_video_quality_high_description
|
||||
VideoCompressionPreset.LOW -> CommonStrings.common_video_quality_low_description
|
||||
}
|
||||
)
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.attachments.preview
|
||||
|
||||
fun interface OnDoneListener {
|
||||
operator fun invoke()
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2022-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.attachments.preview.error
|
||||
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
|
||||
fun sendAttachmentError(
|
||||
throwable: Throwable
|
||||
): Int {
|
||||
return if (throwable is MediaPreProcessor.Failure) {
|
||||
R.string.screen_media_upload_preview_error_failed_processing
|
||||
} else {
|
||||
R.string.screen_media_upload_preview_error_failed_sending
|
||||
}
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.attachments.video
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.mediaupload.api.MaxUploadSizeProvider
|
||||
import io.element.android.libraries.mediaupload.api.compressorHelper
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.first
|
||||
import timber.log.Timber
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
@AssistedInject
|
||||
class DefaultMediaOptimizationSelectorPresenter(
|
||||
@Assisted private val localMedia: LocalMedia,
|
||||
private val maxUploadSizeProvider: MaxUploadSizeProvider,
|
||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
mediaExtractorFactory: VideoMetadataExtractor.Factory,
|
||||
) : MediaOptimizationSelectorPresenter {
|
||||
@ContributesBinding(SessionScope::class)
|
||||
@AssistedFactory
|
||||
interface Factory : MediaOptimizationSelectorPresenter.Factory {
|
||||
override fun create(
|
||||
localMedia: LocalMedia,
|
||||
): DefaultMediaOptimizationSelectorPresenter
|
||||
}
|
||||
|
||||
private val mediaExtractor = mediaExtractorFactory.create(localMedia.uri)
|
||||
|
||||
@Composable
|
||||
override fun present(): MediaOptimizationSelectorState {
|
||||
val displayMediaSelectorViews by produceState<Boolean?>(null) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality)
|
||||
}
|
||||
|
||||
var displayVideoPresetSelectorDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val maxUploadSize by produceState(AsyncData.Loading()) {
|
||||
maxUploadSizeProvider.getMaxUploadSize().fold(
|
||||
onSuccess = { value = AsyncData.Success(it) },
|
||||
onFailure = {
|
||||
Timber.e(it, "Failed to retrieve max upload size for video optimization selector")
|
||||
value = AsyncData.Success((100 * 1024 * 1024).toLong()) // Default to 100 MB if we can't retrieve the max upload size
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val mediaMimeType = localMedia.info.mimeType
|
||||
|
||||
val videoSizeEstimations by produceState<AsyncData<ImmutableList<VideoUploadEstimation>>>(
|
||||
initialValue = AsyncData.Loading(),
|
||||
key1 = maxUploadSize,
|
||||
) {
|
||||
if (maxUploadSize !is AsyncData.Success) {
|
||||
return@produceState
|
||||
}
|
||||
|
||||
if (!mediaMimeType.isMimeTypeVideo()) {
|
||||
value = AsyncData.Uninitialized
|
||||
return@produceState
|
||||
}
|
||||
|
||||
val (videoDimensions, duration) = mediaExtractor.use {
|
||||
val size = it.getSize()
|
||||
.getOrElse { exception ->
|
||||
value = AsyncData.Failure(exception)
|
||||
return@produceState
|
||||
}
|
||||
|
||||
val duration = it.getDuration()
|
||||
.getOrElse { exception ->
|
||||
value = AsyncData.Failure(exception)
|
||||
return@produceState
|
||||
}
|
||||
size to duration
|
||||
}
|
||||
|
||||
val sizeEstimations = VideoCompressionPreset.entries
|
||||
.map { preset ->
|
||||
val bitRateAsBytes = preset.compressorHelper().calculateOptimalBitrate(videoDimensions, 30) / 8f
|
||||
val durationInSeconds = duration.inWholeSeconds.toFloat()
|
||||
val calculatedSize = (bitRateAsBytes * durationInSeconds * 1.1f).roundToLong() // Adding 10% overhead for safety
|
||||
VideoUploadEstimation(
|
||||
preset = preset,
|
||||
sizeInBytes = calculatedSize,
|
||||
canUpload = calculatedSize <= (maxUploadSize as AsyncData.Success).data
|
||||
)
|
||||
}
|
||||
.toImmutableList()
|
||||
.also { sizes ->
|
||||
Timber.d(sizes.joinToString("\n") { "Calculated size for ${it.preset}: ${it.sizeInBytes} MB. Max upload size: $maxUploadSize" })
|
||||
}
|
||||
|
||||
value = AsyncData.Success(sizeEstimations)
|
||||
}
|
||||
|
||||
var selectedImageOptimization by remember { mutableStateOf<AsyncData<Boolean>>(AsyncData.Loading()) }
|
||||
var selectedVideoOptimizationPreset by remember { mutableStateOf<AsyncData<VideoCompressionPreset>>(AsyncData.Loading()) }
|
||||
|
||||
LaunchedEffect(videoSizeEstimations.dataOrNull()) {
|
||||
selectedImageOptimization = AsyncData.Success(sessionPreferencesStore.doesOptimizeImages().first())
|
||||
// Find the best video preset based on the default preset and the video size estimations
|
||||
// Since the estimation for the current preset may be way too large to upload, we check the ones that provide lower file sizes
|
||||
selectedVideoOptimizationPreset = findBestVideoPreset(
|
||||
defaultVideoPreset = sessionPreferencesStore.getVideoCompressionPreset().first(),
|
||||
videoSizeEstimations = videoSizeEstimations,
|
||||
)
|
||||
}
|
||||
|
||||
fun handleEvent(event: MediaOptimizationSelectorEvent) {
|
||||
when (event) {
|
||||
is MediaOptimizationSelectorEvent.SelectImageOptimization -> {
|
||||
selectedImageOptimization = AsyncData.Success(event.enabled)
|
||||
}
|
||||
is MediaOptimizationSelectorEvent.SelectVideoPreset -> {
|
||||
val estimations = videoSizeEstimations.dataOrNull()
|
||||
if (estimations != null) {
|
||||
val preset = estimations.find { it.preset == event.preset }
|
||||
if (preset == null) {
|
||||
Timber.e("Selected video preset ${event.preset} is not available in the estimations")
|
||||
return
|
||||
}
|
||||
if (!preset.canUpload) {
|
||||
Timber.w("Selected video preset ${event.preset} exceeds max upload size")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
Timber.e("Video size estimations are not available")
|
||||
return
|
||||
}
|
||||
selectedVideoOptimizationPreset = AsyncData.Success(event.preset)
|
||||
displayVideoPresetSelectorDialog = false
|
||||
}
|
||||
is MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog -> {
|
||||
displayVideoPresetSelectorDialog = true
|
||||
}
|
||||
is MediaOptimizationSelectorEvent.DismissVideoPresetSelectorDialog -> {
|
||||
displayVideoPresetSelectorDialog = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return MediaOptimizationSelectorState(
|
||||
maxUploadSize = maxUploadSize,
|
||||
videoSizeEstimations = videoSizeEstimations,
|
||||
isImageOptimizationEnabled = selectedImageOptimization.dataOrNull(),
|
||||
selectedVideoPreset = selectedVideoOptimizationPreset.dataOrNull(),
|
||||
displayMediaSelectorViews = displayMediaSelectorViews,
|
||||
displayVideoPresetSelectorDialog = displayVideoPresetSelectorDialog,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
private fun findBestVideoPreset(
|
||||
defaultVideoPreset: VideoCompressionPreset,
|
||||
videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>>,
|
||||
): AsyncData<VideoCompressionPreset> {
|
||||
val estimations = videoSizeEstimations.dataOrNull() ?: return AsyncData.Loading()
|
||||
// This will find the best video preset that can be used to produce a video that can be uploaded
|
||||
val bestEstimation = estimations.find { it.preset.ordinal >= defaultVideoPreset.ordinal && it.canUpload }?.preset
|
||||
return if (bestEstimation != null) {
|
||||
AsyncData.Success(bestEstimation)
|
||||
} else {
|
||||
AsyncData.Failure(
|
||||
IllegalStateException("No suitable video preset found for default preset: $defaultVideoPreset")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.attachments.video
|
||||
|
||||
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||
|
||||
sealed interface MediaOptimizationSelectorEvent {
|
||||
data class SelectImageOptimization(val enabled: Boolean) : MediaOptimizationSelectorEvent
|
||||
data class SelectVideoPreset(val preset: VideoCompressionPreset) : MediaOptimizationSelectorEvent
|
||||
data object OpenVideoPresetSelectorDialog : MediaOptimizationSelectorEvent
|
||||
data object DismissVideoPresetSelectorDialog : MediaOptimizationSelectorEvent
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.attachments.video
|
||||
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||
|
||||
fun interface MediaOptimizationSelectorPresenter : Presenter<MediaOptimizationSelectorState> {
|
||||
interface Factory {
|
||||
fun create(
|
||||
localMedia: LocalMedia,
|
||||
): MediaOptimizationSelectorPresenter
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.attachments.video
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class MediaOptimizationSelectorState(
|
||||
val maxUploadSize: AsyncData<Long>,
|
||||
val videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>>,
|
||||
val isImageOptimizationEnabled: Boolean?,
|
||||
val selectedVideoPreset: VideoCompressionPreset?,
|
||||
val displayMediaSelectorViews: Boolean?,
|
||||
val displayVideoPresetSelectorDialog: Boolean,
|
||||
val eventSink: (MediaOptimizationSelectorEvent) -> Unit
|
||||
)
|
||||
|
||||
data class VideoUploadEstimation(
|
||||
val preset: VideoCompressionPreset,
|
||||
val sizeInBytes: Long,
|
||||
val canUpload: Boolean,
|
||||
)
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.attachments.video
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.util.Size
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
interface VideoMetadataExtractor : AutoCloseable {
|
||||
fun getSize(): Result<Size>
|
||||
fun getDuration(): Result<Duration>
|
||||
interface Factory {
|
||||
fun create(uri: Uri): VideoMetadataExtractor
|
||||
}
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@AssistedInject
|
||||
class DefaultVideoMetadataExtractor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@Assisted private val uri: Uri,
|
||||
) : VideoMetadataExtractor {
|
||||
@ContributesBinding(AppScope::class)
|
||||
@AssistedFactory
|
||||
interface Factory : VideoMetadataExtractor.Factory {
|
||||
override fun create(uri: Uri): DefaultVideoMetadataExtractor
|
||||
}
|
||||
|
||||
// Don't use `by lazy` so we can catch any exceptions thrown during initialization
|
||||
private val mediaMetadataRetriever = lazy {
|
||||
MediaMetadataRetriever().apply {
|
||||
setDataSource(context, uri)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSize(): Result<Size> = runCatchingExceptions {
|
||||
val width = mediaMetadataRetriever.value.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt()
|
||||
val height = mediaMetadataRetriever.value.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt()
|
||||
|
||||
@Suppress("ComplexCondition")
|
||||
if (width != null && width > 0 && height != null && height > 0) {
|
||||
Size(width, height)
|
||||
} else {
|
||||
error("Could not retrieve video size from metadata for $uri")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDuration(): Result<Duration> = runCatchingExceptions {
|
||||
mediaMetadataRetriever.value.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong()
|
||||
?.takeIf { it > 0L }
|
||||
?.milliseconds
|
||||
?: error("Could not retrieve video duration from metadata")
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (mediaMetadataRetriever.isInitialized()) {
|
||||
mediaMetadataRetriever.value.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.crypto.identity
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
sealed interface IdentityChangeEvent {
|
||||
data class PinIdentity(val userId: UserId) : IdentityChangeEvent
|
||||
data class WithdrawVerification(val userId: UserId) : IdentityChangeEvent
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.crypto.identity
|
||||
|
||||
import io.element.android.libraries.matrix.ui.room.RoomMemberIdentityStateChange
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class IdentityChangeState(
|
||||
val roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange>,
|
||||
val eventSink: (IdentityChangeEvent) -> Unit,
|
||||
)
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.crypto.identity
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
@Inject
|
||||
class IdentityChangeStatePresenter(
|
||||
private val room: JoinedRoom,
|
||||
private val encryptionService: EncryptionService,
|
||||
) : Presenter<IdentityChangeState> {
|
||||
@Composable
|
||||
override fun present(): IdentityChangeState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val roomMemberIdentityStateChange by produceState(persistentListOf()) {
|
||||
room.roomMemberIdentityStateChange(waitForEncryption = true).collect { value = it }
|
||||
}
|
||||
|
||||
fun handleEvent(event: IdentityChangeEvent) {
|
||||
when (event) {
|
||||
is IdentityChangeEvent.WithdrawVerification -> {
|
||||
coroutineScope.withdrawVerification(event.userId)
|
||||
}
|
||||
is IdentityChangeEvent.PinIdentity -> {
|
||||
coroutineScope.pinUserIdentity(event.userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return IdentityChangeState(
|
||||
roomMemberIdentityStateChanges = roomMemberIdentityStateChange,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.pinUserIdentity(userId: UserId) = launch {
|
||||
encryptionService.pinUserIdentity(userId)
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to pin identity for user $userId")
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.withdrawVerification(userId: UserId) = launch {
|
||||
encryptionService.withdrawVerification(userId)
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to withdraw verification for user $userId")
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.crypto.identity
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.ui.room.IdentityRoomMember
|
||||
import io.element.android.libraries.matrix.ui.room.RoomMemberIdentityStateChange
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
class IdentityChangeStateProvider : PreviewParameterProvider<IdentityChangeState> {
|
||||
override val values: Sequence<IdentityChangeState>
|
||||
get() = sequenceOf(
|
||||
anIdentityChangeState(),
|
||||
anIdentityChangeState(
|
||||
roomMemberIdentityStateChanges = listOf(
|
||||
aRoomMemberIdentityStateChange(
|
||||
identityRoomMember = anIdentityRoomMember(),
|
||||
identityState = IdentityState.PinViolation,
|
||||
),
|
||||
),
|
||||
),
|
||||
anIdentityChangeState(
|
||||
roomMemberIdentityStateChanges = listOf(
|
||||
aRoomMemberIdentityStateChange(
|
||||
identityRoomMember = anIdentityRoomMember(displayNameOrDefault = "Alice"),
|
||||
identityState = IdentityState.VerificationViolation,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aRoomMemberIdentityStateChange(
|
||||
identityRoomMember: IdentityRoomMember = anIdentityRoomMember(),
|
||||
identityState: IdentityState = IdentityState.PinViolation,
|
||||
) = RoomMemberIdentityStateChange(
|
||||
identityRoomMember = identityRoomMember,
|
||||
identityState = identityState,
|
||||
)
|
||||
|
||||
internal fun anIdentityChangeState(
|
||||
roomMemberIdentityStateChanges: List<RoomMemberIdentityStateChange> = emptyList(),
|
||||
eventSink: (IdentityChangeEvent) -> Unit = {},
|
||||
) = IdentityChangeState(
|
||||
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges.toImmutableList(),
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
internal fun anIdentityRoomMember(
|
||||
userId: UserId = UserId("@alice:example.com"),
|
||||
displayNameOrDefault: String = userId.extractedDisplayName,
|
||||
avatarData: AvatarData = AvatarData(
|
||||
id = userId.value,
|
||||
name = null,
|
||||
url = null,
|
||||
size = AvatarSize.ComposerAlert,
|
||||
),
|
||||
) = IdentityRoomMember(
|
||||
userId = userId,
|
||||
displayNameOrDefault = displayNameOrDefault,
|
||||
avatarData = avatarData,
|
||||
)
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.crypto.identity
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.appconfig.LearnMoreConfig
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertLevel
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.isAViolation
|
||||
import io.element.android.libraries.matrix.ui.room.RoomMemberIdentityStateChange
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun IdentityChangeStateView(
|
||||
state: IdentityChangeState,
|
||||
onLinkClick: (String, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Pick the first identity change that is a violation
|
||||
val identityChangeViolation = state.roomMemberIdentityStateChanges.firstOrNull {
|
||||
it.identityState.isAViolation()
|
||||
}
|
||||
when (identityChangeViolation?.identityState) {
|
||||
IdentityState.PinViolation -> ViolationAlert(
|
||||
identityChangeViolation = identityChangeViolation,
|
||||
onLinkClick = onLinkClick,
|
||||
textId = CommonStrings.crypto_identity_change_pin_violation_new,
|
||||
isCritical = false,
|
||||
submitTextId = CommonStrings.action_dismiss,
|
||||
onSubmitClick = { state.eventSink(IdentityChangeEvent.PinIdentity(identityChangeViolation.identityRoomMember.userId)) },
|
||||
modifier = modifier,
|
||||
)
|
||||
IdentityState.VerificationViolation -> ViolationAlert(
|
||||
identityChangeViolation = identityChangeViolation,
|
||||
onLinkClick = onLinkClick,
|
||||
textId = CommonStrings.crypto_identity_change_verification_violation_new,
|
||||
isCritical = true,
|
||||
submitTextId = CommonStrings.crypto_identity_change_withdraw_verification_action,
|
||||
onSubmitClick = { state.eventSink(IdentityChangeEvent.WithdrawVerification(identityChangeViolation.identityRoomMember.userId)) },
|
||||
modifier = modifier,
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ViolationAlert(
|
||||
identityChangeViolation: RoomMemberIdentityStateChange,
|
||||
onLinkClick: (String, Boolean) -> Unit,
|
||||
@StringRes textId: Int,
|
||||
isCritical: Boolean,
|
||||
@StringRes submitTextId: Int,
|
||||
onSubmitClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ComposerAlertMolecule(
|
||||
modifier = modifier,
|
||||
avatar = identityChangeViolation.identityRoomMember.avatarData,
|
||||
content = buildAnnotatedString {
|
||||
val learnMoreStr = stringResource(CommonStrings.action_learn_more)
|
||||
val displayName = identityChangeViolation.identityRoomMember.displayNameOrDefault
|
||||
val userIdStr = stringResource(
|
||||
CommonStrings.crypto_identity_change_pin_violation_new_user_id,
|
||||
identityChangeViolation.identityRoomMember.userId,
|
||||
)
|
||||
val fullText = stringResource(textId, displayName, userIdStr, learnMoreStr)
|
||||
append(fullText)
|
||||
val userIdStartIndex = fullText.indexOf(userIdStr)
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
start = userIdStartIndex,
|
||||
end = userIdStartIndex + userIdStr.length,
|
||||
)
|
||||
val learnMoreStartIndex = fullText.lastIndexOf(learnMoreStr)
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = ElementTheme.colors.textPrimary
|
||||
),
|
||||
start = learnMoreStartIndex,
|
||||
end = learnMoreStartIndex + learnMoreStr.length,
|
||||
)
|
||||
addLink(
|
||||
url = LinkAnnotation.Url(
|
||||
url = LearnMoreConfig.IDENTITY_CHANGE_URL,
|
||||
linkInteractionListener = {
|
||||
onLinkClick(LearnMoreConfig.IDENTITY_CHANGE_URL, true)
|
||||
}
|
||||
),
|
||||
start = learnMoreStartIndex,
|
||||
end = learnMoreStartIndex + learnMoreStr.length,
|
||||
)
|
||||
},
|
||||
submitText = stringResource(submitTextId),
|
||||
onSubmitClick = onSubmitClick,
|
||||
level = if (isCritical) ComposerAlertLevel.Critical else ComposerAlertLevel.Default,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun IdentityChangeStateViewPreview(
|
||||
@PreviewParameter(IdentityChangeStateProvider::class) state: IdentityChangeState,
|
||||
) = ElementPreview {
|
||||
IdentityChangeStateView(
|
||||
state = state,
|
||||
onLinkClick = { _, _ -> },
|
||||
)
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.crypto.identity
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.features.messages.impl.MessagesView
|
||||
import io.element.android.features.messages.impl.aMessagesState
|
||||
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MessagesViewWithIdentityChangePreview(
|
||||
@PreviewParameter(IdentityChangeStateProvider::class) identityChangeState: IdentityChangeState
|
||||
) = ElementPreview {
|
||||
MessagesView(
|
||||
state = aMessagesState(
|
||||
composerState = aMessageComposerState(
|
||||
textEditorState = aTextEditorStateMarkdown(
|
||||
initialText = "",
|
||||
initialFocus = false,
|
||||
)
|
||||
),
|
||||
identityChangeState = identityChangeState,
|
||||
),
|
||||
onBackClick = {},
|
||||
onRoomDetailsClick = {},
|
||||
onEventContentClick = { _, _ -> false },
|
||||
onUserDataClick = {},
|
||||
onLinkClick = { _, _ -> },
|
||||
onSendLocationClick = {},
|
||||
onCreatePollClick = {},
|
||||
onJoinCallClick = {},
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
knockRequestsBannerView = {}
|
||||
)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.crypto.sendfailure
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
sealed interface VerifiedUserSendFailure {
|
||||
data object None : VerifiedUserSendFailure
|
||||
|
||||
sealed interface UnsignedDevice : VerifiedUserSendFailure {
|
||||
data object FromYou : UnsignedDevice
|
||||
data class FromOther(val userDisplayName: String) : UnsignedDevice
|
||||
}
|
||||
|
||||
data class ChangedIdentity(
|
||||
val userDisplayName: String,
|
||||
) : VerifiedUserSendFailure
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.crypto.sendfailure
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
|
||||
@Inject
|
||||
class VerifiedUserSendFailureFactory(
|
||||
private val room: BaseRoom,
|
||||
) {
|
||||
suspend fun create(
|
||||
sendState: LocalEventSendState?,
|
||||
): VerifiedUserSendFailure {
|
||||
return when (sendState) {
|
||||
is LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice -> {
|
||||
val userId = sendState.devices.keys.firstOrNull()
|
||||
if (userId == null) {
|
||||
VerifiedUserSendFailure.None
|
||||
} else {
|
||||
if (userId == room.sessionId) {
|
||||
VerifiedUserSendFailure.UnsignedDevice.FromYou
|
||||
} else {
|
||||
val displayName = room.userDisplayName(userId).getOrNull() ?: userId.value
|
||||
VerifiedUserSendFailure.UnsignedDevice.FromOther(displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
is LocalEventSendState.Failed.VerifiedUserChangedIdentity -> {
|
||||
val userId = sendState.users.firstOrNull()
|
||||
if (userId == null) {
|
||||
VerifiedUserSendFailure.None
|
||||
} else {
|
||||
val displayName = room.userDisplayName(userId).getOrNull() ?: userId.value
|
||||
VerifiedUserSendFailure.ChangedIdentity(displayName)
|
||||
}
|
||||
}
|
||||
else -> VerifiedUserSendFailure.None
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.crypto.sendfailure.resolve
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
|
||||
sealed interface ResolveVerifiedUserSendFailureEvents {
|
||||
data class ComputeForMessage(
|
||||
val messageEvent: TimelineItem.Event,
|
||||
) : ResolveVerifiedUserSendFailureEvents
|
||||
|
||||
data object ResolveAndResend : ResolveVerifiedUserSendFailureEvents
|
||||
data object Retry : ResolveVerifiedUserSendFailureEvents
|
||||
data object Dismiss : ResolveVerifiedUserSendFailureEvents
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.crypto.sendfailure.resolve
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runUpdatingState
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Inject
|
||||
class ResolveVerifiedUserSendFailurePresenter(
|
||||
private val room: JoinedRoom,
|
||||
private val verifiedUserSendFailureFactory: VerifiedUserSendFailureFactory,
|
||||
) : Presenter<ResolveVerifiedUserSendFailureState> {
|
||||
@Composable
|
||||
override fun present(): ResolveVerifiedUserSendFailureState {
|
||||
var resolver by remember {
|
||||
mutableStateOf<VerifiedUserSendFailureResolver?>(null)
|
||||
}
|
||||
val verifiedUserSendFailure by produceState<VerifiedUserSendFailure>(VerifiedUserSendFailure.None, resolver?.currentSendFailure?.value) {
|
||||
val currentSendFailure = resolver?.currentSendFailure?.value
|
||||
value = verifiedUserSendFailureFactory.create(currentSendFailure)
|
||||
}
|
||||
|
||||
val resolveAction = remember {
|
||||
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
||||
}
|
||||
val retryAction = remember {
|
||||
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
||||
}
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
fun handleEvent(event: ResolveVerifiedUserSendFailureEvents) {
|
||||
when (event) {
|
||||
is ResolveVerifiedUserSendFailureEvents.ComputeForMessage -> {
|
||||
val sendState = event.messageEvent.localSendState as? LocalEventSendState.Failed.VerifiedUser
|
||||
val transactionId = event.messageEvent.transactionId
|
||||
val sendHandle = event.messageEvent.sendhandle
|
||||
resolver = if (sendState != null && transactionId != null && sendHandle != null) {
|
||||
VerifiedUserSendFailureResolver(
|
||||
room = room,
|
||||
transactionId = transactionId,
|
||||
sendHandle = sendHandle,
|
||||
iterator = VerifiedUserSendFailureIterator.from(sendState)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
ResolveVerifiedUserSendFailureEvents.Dismiss -> {
|
||||
resolver = null
|
||||
}
|
||||
ResolveVerifiedUserSendFailureEvents.Retry -> {
|
||||
coroutineScope.launch {
|
||||
resolver?.run {
|
||||
runUpdatingState(retryAction) {
|
||||
resend()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ResolveVerifiedUserSendFailureEvents.ResolveAndResend -> {
|
||||
coroutineScope.launch {
|
||||
resolver?.run {
|
||||
runUpdatingState(resolveAction) {
|
||||
resolveAndResend()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ResolveVerifiedUserSendFailureState(
|
||||
verifiedUserSendFailure = verifiedUserSendFailure,
|
||||
resolveAction = resolveAction.value,
|
||||
retryAction = retryAction.value,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.crypto.sendfailure.resolve
|
||||
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
|
||||
data class ResolveVerifiedUserSendFailureState(
|
||||
val verifiedUserSendFailure: VerifiedUserSendFailure,
|
||||
val resolveAction: AsyncAction<Unit>,
|
||||
val retryAction: AsyncAction<Unit>,
|
||||
val eventSink: (ResolveVerifiedUserSendFailureEvents) -> Unit
|
||||
)
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.crypto.sendfailure.resolve
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
|
||||
open class ResolveVerifiedUserSendFailureStateProvider : PreviewParameterProvider<ResolveVerifiedUserSendFailureState> {
|
||||
override val values: Sequence<ResolveVerifiedUserSendFailureState>
|
||||
get() = sequenceOf(
|
||||
aResolveVerifiedUserSendFailureState(),
|
||||
aResolveVerifiedUserSendFailureState(
|
||||
verifiedUserSendFailure = anUnsignedDeviceSendFailure()
|
||||
),
|
||||
aResolveVerifiedUserSendFailureState(
|
||||
verifiedUserSendFailure = aChangedIdentitySendFailure()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun aResolveVerifiedUserSendFailureState(
|
||||
verifiedUserSendFailure: VerifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
resolveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
retryAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
eventSink: (ResolveVerifiedUserSendFailureEvents) -> Unit = {}
|
||||
) = ResolveVerifiedUserSendFailureState(
|
||||
verifiedUserSendFailure = verifiedUserSendFailure,
|
||||
resolveAction = resolveAction,
|
||||
retryAction = retryAction,
|
||||
eventSink = eventSink
|
||||
)
|
||||
|
||||
fun anUnsignedDeviceSendFailure(userDisplayName: String = "Alice") = VerifiedUserSendFailure.UnsignedDevice.FromOther(
|
||||
userDisplayName = userDisplayName,
|
||||
)
|
||||
|
||||
fun aChangedIdentitySendFailure(userDisplayName: String = "Alice") = VerifiedUserSendFailure.ChangedIdentity(
|
||||
userDisplayName = userDisplayName,
|
||||
)
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.crypto.sendfailure.resolve
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ResolveVerifiedUserSendFailureView(
|
||||
state: ResolveVerifiedUserSendFailureState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
var showSheet by remember { mutableStateOf(false) }
|
||||
|
||||
fun dismiss() {
|
||||
state.eventSink(ResolveVerifiedUserSendFailureEvents.Dismiss)
|
||||
}
|
||||
|
||||
fun onRetryClick() {
|
||||
state.eventSink(ResolveVerifiedUserSendFailureEvents.Retry)
|
||||
}
|
||||
|
||||
fun onResolveAndResendClick() {
|
||||
state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend)
|
||||
}
|
||||
|
||||
LaunchedEffect(state.verifiedUserSendFailure) {
|
||||
if (state.verifiedUserSendFailure is VerifiedUserSendFailure.None) {
|
||||
sheetState.hide()
|
||||
showSheet = false
|
||||
} else {
|
||||
showSheet = true
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
if (showSheet) {
|
||||
ModalBottomSheet(
|
||||
modifier = Modifier
|
||||
.systemBarsPadding()
|
||||
.navigationBarsPadding(),
|
||||
sheetState = sheetState,
|
||||
onDismissRequest = ::dismiss,
|
||||
) {
|
||||
IconTitleSubtitleMolecule(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
title = state.verifiedUserSendFailure.title(),
|
||||
subTitle = state.verifiedUserSendFailure.subtitle(),
|
||||
iconStyle = BigIcon.Style.AlertSolid,
|
||||
)
|
||||
ButtonColumnMolecule(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
) {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = state.verifiedUserSendFailure.resolveAction(),
|
||||
showProgress = state.resolveAction.isLoading(),
|
||||
onClick = ::onResolveAndResendClick
|
||||
)
|
||||
OutlinedButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(id = CommonStrings.action_retry),
|
||||
showProgress = state.retryAction.isLoading(),
|
||||
onClick = ::onRetryClick
|
||||
)
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(id = CommonStrings.action_cancel_for_now),
|
||||
onClick = ::dismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerifiedUserSendFailure.title(): String {
|
||||
return when (this) {
|
||||
is VerifiedUserSendFailure.UnsignedDevice.FromOther -> stringResource(
|
||||
id = CommonStrings.screen_resolve_send_failure_unsigned_device_title,
|
||||
userDisplayName
|
||||
)
|
||||
VerifiedUserSendFailure.UnsignedDevice.FromYou -> stringResource(id = CommonStrings.screen_resolve_send_failure_you_unsigned_device_title)
|
||||
is VerifiedUserSendFailure.ChangedIdentity -> stringResource(
|
||||
id = CommonStrings.screen_resolve_send_failure_changed_identity_title,
|
||||
userDisplayName
|
||||
)
|
||||
VerifiedUserSendFailure.None -> ""
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerifiedUserSendFailure.subtitle(): String {
|
||||
return when (this) {
|
||||
is VerifiedUserSendFailure.UnsignedDevice.FromOther -> stringResource(
|
||||
id = CommonStrings.screen_resolve_send_failure_unsigned_device_subtitle,
|
||||
userDisplayName,
|
||||
userDisplayName,
|
||||
)
|
||||
VerifiedUserSendFailure.UnsignedDevice.FromYou -> stringResource(id = CommonStrings.screen_resolve_send_failure_you_unsigned_device_subtitle)
|
||||
is VerifiedUserSendFailure.ChangedIdentity -> stringResource(
|
||||
id = CommonStrings.screen_resolve_send_failure_changed_identity_subtitle,
|
||||
userDisplayName
|
||||
)
|
||||
VerifiedUserSendFailure.None -> ""
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerifiedUserSendFailure.resolveAction(): String {
|
||||
return when (this) {
|
||||
is VerifiedUserSendFailure.UnsignedDevice -> stringResource(id = CommonStrings.screen_resolve_send_failure_unsigned_device_primary_button_title)
|
||||
is VerifiedUserSendFailure.ChangedIdentity -> stringResource(id = CommonStrings.screen_resolve_send_failure_changed_identity_primary_button_title)
|
||||
VerifiedUserSendFailure.None -> ""
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ResolveVerifiedUserSendFailureViewPreview(
|
||||
@PreviewParameter(ResolveVerifiedUserSendFailureStateProvider::class) state: ResolveVerifiedUserSendFailureState
|
||||
) = ElementPreview {
|
||||
ResolveVerifiedUserSendFailureView(state)
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.crypto.sendfailure.resolve
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Iterator for [LocalEventSendState.Failed.VerifiedUser]
|
||||
* Allow to iterate through the internal state of the failure.
|
||||
* This is useful to allow solving the failure step by step (e.g. for each user).
|
||||
*/
|
||||
interface VerifiedUserSendFailureIterator : Iterator<LocalEventSendState.Failed.VerifiedUser> {
|
||||
companion object {
|
||||
fun from(failure: LocalEventSendState.Failed.VerifiedUser): VerifiedUserSendFailureIterator {
|
||||
return when (failure) {
|
||||
is LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice -> UnsignedDeviceSendFailureIterator(failure)
|
||||
is LocalEventSendState.Failed.VerifiedUserChangedIdentity -> ChangedIdentitySendFailureIterator(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UnsignedDeviceSendFailureIterator(
|
||||
failure: LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice
|
||||
) : VerifiedUserSendFailureIterator {
|
||||
private val iterator = failure.devices.iterator()
|
||||
|
||||
init {
|
||||
if (!hasNext()) {
|
||||
Timber.w("Got $failure without any devices, shouldn't happen.")
|
||||
}
|
||||
}
|
||||
|
||||
override fun hasNext(): Boolean {
|
||||
return iterator.hasNext()
|
||||
}
|
||||
|
||||
override fun next(): LocalEventSendState.Failed.VerifiedUser {
|
||||
val (userId, deviceIds) = iterator.next()
|
||||
return LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice(
|
||||
mapOf(userId to deviceIds)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class ChangedIdentitySendFailureIterator(
|
||||
failure: LocalEventSendState.Failed.VerifiedUserChangedIdentity
|
||||
) : VerifiedUserSendFailureIterator {
|
||||
private val iterator = failure.users.iterator()
|
||||
|
||||
init {
|
||||
if (!hasNext()) {
|
||||
Timber.w("Got $failure without any users, shouldn't happen.")
|
||||
}
|
||||
}
|
||||
|
||||
override fun hasNext(): Boolean {
|
||||
return iterator.hasNext()
|
||||
}
|
||||
|
||||
override fun next(): LocalEventSendState.Failed.VerifiedUser {
|
||||
val userId = iterator.next()
|
||||
return LocalEventSendState.Failed.VerifiedUserChangedIdentity(
|
||||
listOf(userId)
|
||||
)
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.crypto.sendfailure.resolve
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import io.element.android.libraries.matrix.api.core.SendHandle
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* This class is responsible for resolving and resending a failed message sent to a verified user.
|
||||
* It also allow to resend the message without resolving the failure, for example if the user has in the meantime verified their device again.
|
||||
* It's using the [VerifiedUserSendFailureIterator] to iterate over the different failures (ie. the different users concerned by the failure).
|
||||
* This way, the user can resolve and resend the message for each user concerned, one by one.
|
||||
*/
|
||||
class VerifiedUserSendFailureResolver(
|
||||
private val room: JoinedRoom,
|
||||
private val transactionId: TransactionId,
|
||||
private val sendHandle: SendHandle,
|
||||
private val iterator: VerifiedUserSendFailureIterator,
|
||||
) {
|
||||
val currentSendFailure = mutableStateOf<LocalEventSendState.Failed.VerifiedUser?>(null)
|
||||
|
||||
init {
|
||||
if (iterator.hasNext()) {
|
||||
currentSendFailure.value = iterator.next()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resend(): Result<Unit> {
|
||||
return sendHandle.retry()
|
||||
.onSuccess {
|
||||
Timber.d("Succeed to resend message with transactionId: $transactionId")
|
||||
currentSendFailure.value = null
|
||||
}
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to resend message with transactionId: $transactionId")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resolveAndResend(): Result<Unit> {
|
||||
return when (val failure = currentSendFailure.value) {
|
||||
is LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice -> {
|
||||
room.ignoreDeviceTrustAndResend(failure.devices, sendHandle)
|
||||
}
|
||||
is LocalEventSendState.Failed.VerifiedUserChangedIdentity -> {
|
||||
room.withdrawVerificationAndResend(failure.users, sendHandle)
|
||||
}
|
||||
else -> {
|
||||
Result.failure(IllegalStateException("Unknown send failure type"))
|
||||
}
|
||||
}.onSuccess {
|
||||
Timber.d("Succeed to resolve and resend message with transactionId: $transactionId")
|
||||
if (iterator.hasNext()) {
|
||||
val failure = iterator.next()
|
||||
currentSendFailure.value = failure
|
||||
} else {
|
||||
currentSendFailure.value = null
|
||||
Timber.d("No more failure to resolve for transactionId: $transactionId")
|
||||
}
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to resolve and resend message with transactionId: $transactionId")
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.di
|
||||
|
||||
import dev.zacsweers.metro.BindingContainer
|
||||
import dev.zacsweers.metro.Binds
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
|
||||
import io.element.android.features.messages.impl.link.LinkPresenter
|
||||
import io.element.android.features.messages.impl.link.LinkState
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetPresenter
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionPresenter
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationPresenter
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
|
||||
@ContributesTo(RoomScope::class)
|
||||
@BindingContainer
|
||||
interface MessagesBindsModule {
|
||||
@Binds
|
||||
fun bindPinnedMessagesBannerPresenter(presenter: PinnedMessagesBannerPresenter): Presenter<PinnedMessagesBannerState>
|
||||
|
||||
@Binds
|
||||
fun bindResolveVerifiedUserSendFailurePresenter(presenter: ResolveVerifiedUserSendFailurePresenter): Presenter<ResolveVerifiedUserSendFailureState>
|
||||
|
||||
@Binds
|
||||
fun bindTypingNotificationPresenter(presenter: TypingNotificationPresenter): Presenter<TypingNotificationState>
|
||||
|
||||
@Binds
|
||||
fun bindTimelineProtectionPresenter(presenter: TimelineProtectionPresenter): Presenter<TimelineProtectionState>
|
||||
|
||||
@Binds
|
||||
fun bindLinkPresenter(presenter: LinkPresenter): Presenter<LinkState>
|
||||
|
||||
@Binds
|
||||
fun bindCustomReactionPresenter(presenter: CustomReactionPresenter): Presenter<CustomReactionState>
|
||||
|
||||
@Binds
|
||||
fun bindReactionSummaryPresenter(presenter: ReactionSummaryPresenter): Presenter<ReactionSummaryState>
|
||||
|
||||
@Binds
|
||||
fun bindReadReceiptBottomSheetPresenter(presenter: ReadReceiptBottomSheetPresenter): Presenter<ReadReceiptBottomSheetState>
|
||||
|
||||
@Binds
|
||||
fun bindIdentityChangeStatePresenter(presenter: IdentityChangeStatePresenter): Presenter<IdentityChangeState>
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.di
|
||||
|
||||
import dev.zacsweers.metro.BindingContainer
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import dev.zacsweers.metro.Provides
|
||||
import io.element.android.features.messages.impl.timeline.di.LiveTimeline
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
|
||||
@ContributesTo(RoomScope::class)
|
||||
@BindingContainer
|
||||
object MessagesProvidesModule {
|
||||
@Provides
|
||||
@LiveTimeline
|
||||
fun provideLiveTimeline(joinedRoom: JoinedRoom): Timeline = joinedRoom.liveTimeline
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.draft
|
||||
|
||||
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.room.draft.ComposerDraft
|
||||
|
||||
interface ComposerDraftService {
|
||||
suspend fun loadDraft(roomId: RoomId, threadRoot: ThreadId?, isVolatile: Boolean): ComposerDraft?
|
||||
suspend fun updateDraft(roomId: RoomId, threadRoot: ThreadId?, draft: ComposerDraft?, isVolatile: Boolean)
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.draft
|
||||
|
||||
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.room.draft.ComposerDraft
|
||||
|
||||
interface ComposerDraftStore {
|
||||
suspend fun loadDraft(roomId: RoomId, threadRoot: ThreadId?): ComposerDraft?
|
||||
suspend fun updateDraft(roomId: RoomId, threadRoot: ThreadId?, draft: ComposerDraft?)
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.draft
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
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.room.draft.ComposerDraft
|
||||
|
||||
@ContributesBinding(RoomScope::class)
|
||||
class DefaultComposerDraftService(
|
||||
private val volatileComposerDraftStore: VolatileComposerDraftStore,
|
||||
private val matrixComposerDraftStore: MatrixComposerDraftStore,
|
||||
) : ComposerDraftService {
|
||||
override suspend fun loadDraft(roomId: RoomId, threadRoot: ThreadId?, isVolatile: Boolean): ComposerDraft? {
|
||||
return getStore(isVolatile).loadDraft(roomId, threadRoot)
|
||||
}
|
||||
|
||||
override suspend fun updateDraft(roomId: RoomId, threadRoot: ThreadId?, draft: ComposerDraft?, isVolatile: Boolean) {
|
||||
getStore(isVolatile).updateDraft(roomId, threadRoot, draft)
|
||||
}
|
||||
|
||||
private fun getStore(isVolatile: Boolean): ComposerDraftStore {
|
||||
return if (isVolatile) {
|
||||
volatileComposerDraftStore
|
||||
} else {
|
||||
matrixComposerDraftStore
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.draft
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
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.room.draft.ComposerDraft
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* A draft store that persists drafts in the room state.
|
||||
* It can be used to store drafts that should be persisted across app restarts.
|
||||
*/
|
||||
@Inject
|
||||
class MatrixComposerDraftStore(
|
||||
private val client: MatrixClient,
|
||||
) : ComposerDraftStore {
|
||||
override suspend fun loadDraft(roomId: RoomId, threadRoot: ThreadId?): ComposerDraft? {
|
||||
return client.getRoom(roomId)?.use { room ->
|
||||
room.loadComposerDraft(threadRoot)
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to load composer draft for room $roomId")
|
||||
}
|
||||
.onSuccess { draft ->
|
||||
room.clearComposerDraft(threadRoot)
|
||||
Timber.d("Loaded composer draft for room $roomId : $draft")
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateDraft(roomId: RoomId, threadRoot: ThreadId?, draft: ComposerDraft?) {
|
||||
client.getRoom(roomId)?.use { room ->
|
||||
val updateDraftResult = if (draft == null) {
|
||||
room.clearComposerDraft(threadRoot)
|
||||
} else {
|
||||
room.saveComposerDraft(draft, threadRoot)
|
||||
}
|
||||
updateDraftResult
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to update composer draft for room $roomId")
|
||||
}
|
||||
.onSuccess {
|
||||
Timber.d("Updated composer draft for room $roomId")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.draft
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
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.room.draft.ComposerDraft
|
||||
|
||||
/**
|
||||
* A volatile draft store that keeps drafts in memory only.
|
||||
* It can be used to store drafts that should not be persisted across app restarts.
|
||||
* Currently it's used to store draft message when moving to edit mode.
|
||||
*/
|
||||
@Inject
|
||||
class VolatileComposerDraftStore : ComposerDraftStore {
|
||||
private val drafts: MutableMap<String, ComposerDraft> = mutableMapOf()
|
||||
|
||||
override suspend fun loadDraft(roomId: RoomId, threadRoot: ThreadId?): ComposerDraft? {
|
||||
val key = threadRoot?.value ?: roomId.value
|
||||
// Remove the draft from the map when it is loaded
|
||||
return drafts.remove(key)
|
||||
}
|
||||
|
||||
override suspend fun updateDraft(roomId: RoomId, threadRoot: ThreadId?, draft: ComposerDraft?) {
|
||||
val key = threadRoot?.value ?: roomId.value
|
||||
if (draft == null) {
|
||||
drafts.remove(key)
|
||||
} else {
|
||||
drafts[key] = draft
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.link
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
data class ConfirmingLinkClick(
|
||||
val link: Link,
|
||||
) : AsyncAction.Confirming
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.link
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.core.extensions.containsRtLOverride
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import java.net.URI
|
||||
|
||||
interface LinkChecker {
|
||||
fun isSafe(link: Link): Boolean
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultLinkChecker : LinkChecker {
|
||||
override fun isSafe(link: Link): Boolean {
|
||||
return if (link.url.containsRtLOverride()) {
|
||||
false
|
||||
} else {
|
||||
val textUrl = tryOrNull { URI(link.text).toURL() }
|
||||
val urlUrl = tryOrNull { URI(link.url).toURL() }
|
||||
if (textUrl == null || urlUrl == null) {
|
||||
// The text is not a Url, or the url is not valid
|
||||
true
|
||||
} else {
|
||||
// the hosts must match
|
||||
textUrl.host == urlUrl.host
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.link
|
||||
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
sealed interface LinkEvents {
|
||||
data class OnLinkClick(val link: Link) : LinkEvents
|
||||
data object Confirm : LinkEvents
|
||||
data object Cancel : LinkEvents
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.link
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
@Inject
|
||||
class LinkPresenter(
|
||||
private val linkChecker: LinkChecker,
|
||||
) : Presenter<LinkState> {
|
||||
@Composable
|
||||
override fun present(): LinkState {
|
||||
val linkClick: MutableState<AsyncAction<Link>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||
|
||||
fun handleEvent(event: LinkEvents) {
|
||||
when (event) {
|
||||
is LinkEvents.OnLinkClick -> {
|
||||
linkClick.value = AsyncAction.Loading
|
||||
val result = linkChecker.isSafe(event.link)
|
||||
if (result) {
|
||||
linkClick.value = AsyncAction.Success(event.link)
|
||||
} else {
|
||||
// Confirm first
|
||||
linkClick.value = ConfirmingLinkClick(event.link)
|
||||
}
|
||||
}
|
||||
LinkEvents.Confirm -> {
|
||||
linkClick.value = (linkClick.value as? ConfirmingLinkClick)
|
||||
?.let { AsyncAction.Success(it.link) }
|
||||
?: AsyncAction.Uninitialized
|
||||
}
|
||||
LinkEvents.Cancel -> {
|
||||
linkClick.value = AsyncAction.Uninitialized
|
||||
}
|
||||
}
|
||||
}
|
||||
return LinkState(
|
||||
linkClick = linkClick.value,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.link
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
data class LinkState(
|
||||
val linkClick: AsyncAction<Link>,
|
||||
val eventSink: (LinkEvents) -> Unit,
|
||||
)
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.link
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
open class LinkStateProvider : PreviewParameterProvider<LinkState> {
|
||||
override val values: Sequence<LinkState>
|
||||
get() = sequenceOf(
|
||||
aLinkState(),
|
||||
aLinkState(
|
||||
linkClick = ConfirmingLinkClick(
|
||||
Link(
|
||||
url = "https://evil.io",
|
||||
text = "https://element.io"
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aLinkState(
|
||||
linkClick: AsyncAction<Link> = AsyncAction.Uninitialized,
|
||||
eventSink: (LinkEvents) -> Unit = {},
|
||||
) = LinkState(
|
||||
linkClick = linkClick,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.link
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.core.extensions.ensureEndsLeftToRight
|
||||
import io.element.android.libraries.core.extensions.filterDirectionOverrides
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
@Composable
|
||||
fun LinkView(
|
||||
state: LinkState,
|
||||
onLinkValid: (Link) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (state.linkClick) {
|
||||
AsyncAction.Uninitialized,
|
||||
AsyncAction.Loading,
|
||||
is AsyncAction.Failure -> Unit
|
||||
is AsyncAction.Confirming -> {
|
||||
if (state.linkClick is ConfirmingLinkClick) {
|
||||
ConfirmationDialog(
|
||||
modifier = modifier,
|
||||
title = stringResource(CommonStrings.dialog_confirm_link_title),
|
||||
content = stringResource(
|
||||
CommonStrings.dialog_confirm_link_message,
|
||||
state.linkClick.link.text.ensureEndsLeftToRight(),
|
||||
state.linkClick.link.url.filterDirectionOverrides(),
|
||||
),
|
||||
submitText = stringResource(CommonStrings.action_continue),
|
||||
onSubmitClick = {
|
||||
state.eventSink(LinkEvents.Confirm)
|
||||
},
|
||||
onDismiss = {
|
||||
state.eventSink(LinkEvents.Cancel)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
is AsyncAction.Success -> {
|
||||
val latestOnLinkValid by rememberUpdatedState(onLinkValid)
|
||||
LaunchedEffect(state.linkClick.data) {
|
||||
latestOnLinkValid(state.linkClick.data)
|
||||
state.eventSink(LinkEvents.Cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun LinkViewPreview(@PreviewParameter(LinkStateProvider::class) state: LinkState) = ElementPreview {
|
||||
LinkView(
|
||||
state = state,
|
||||
onLinkValid = {},
|
||||
)
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* 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.messagecomposer
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun AttachmentsBottomSheet(
|
||||
state: MessageComposerState,
|
||||
onSendLocationClick: () -> Unit,
|
||||
onCreatePollClick: () -> Unit,
|
||||
enableTextFormatting: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val localView = LocalView.current
|
||||
var isVisible by rememberSaveable { mutableStateOf(state.showAttachmentSourcePicker) }
|
||||
|
||||
BackHandler(enabled = isVisible) {
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
LaunchedEffect(state.showAttachmentSourcePicker) {
|
||||
isVisible = if (state.showAttachmentSourcePicker) {
|
||||
// We need to use this instead of `LocalFocusManager.clearFocus()` to hide the keyboard when focus is on an Android View
|
||||
localView.hideKeyboard()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
// Send 'DismissAttachmentMenu' event when the bottomsheet was just hidden
|
||||
LaunchedEffect(isVisible) {
|
||||
if (!isVisible) {
|
||||
state.eventSink(MessageComposerEvent.DismissAttachmentMenu)
|
||||
}
|
||||
}
|
||||
|
||||
if (isVisible) {
|
||||
ModalBottomSheet(
|
||||
modifier = modifier,
|
||||
sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
),
|
||||
onDismissRequest = { isVisible = false }
|
||||
) {
|
||||
AttachmentSourcePickerMenu(
|
||||
state = state,
|
||||
enableTextFormatting = enableTextFormatting,
|
||||
onSendLocationClick = onSendLocationClick,
|
||||
onCreatePollClick = onCreatePollClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentSourcePickerMenu(
|
||||
state: MessageComposerState,
|
||||
onSendLocationClick: () -> Unit,
|
||||
onCreatePollClick: () -> Unit,
|
||||
enableTextFormatting: Boolean,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.PhotoFromCamera) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.TakePhoto())),
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
|
||||
style = ListItemStyle.Primary,
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.VideoFromCamera) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.VideoCall())),
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
|
||||
style = ListItemStyle.Primary,
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.FromGallery) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())),
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
|
||||
style = ListItemStyle.Primary,
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.FromFiles) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Attachment())),
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
|
||||
style = ListItemStyle.Primary,
|
||||
)
|
||||
if (state.canShareLocation) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
state.eventSink(MessageComposerEvent.PickAttachmentSource.Location)
|
||||
onSendLocationClick()
|
||||
},
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())),
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
|
||||
style = ListItemStyle.Primary,
|
||||
)
|
||||
}
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
state.eventSink(MessageComposerEvent.PickAttachmentSource.Poll)
|
||||
onCreatePollClick()
|
||||
},
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Polls())),
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
|
||||
style = ListItemStyle.Primary,
|
||||
)
|
||||
if (enableTextFormatting) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.ToggleTextFormatting(enabled = true)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.TextFormatting())),
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_attachment_text_formatting)) },
|
||||
style = ListItemStyle.Primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun AttachmentSourcePickerMenuPreview() = ElementPreview {
|
||||
AttachmentSourcePickerMenu(
|
||||
state = aMessageComposerState(
|
||||
canShareLocation = true,
|
||||
),
|
||||
onSendLocationClick = {},
|
||||
onCreatePollClick = {},
|
||||
enableTextFormatting = true,
|
||||
)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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.messagecomposer
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.features.messages.api.MessageComposerContext
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
|
||||
@SingleIn(RoomScope::class)
|
||||
@ContributesBinding(RoomScope::class)
|
||||
class DefaultMessageComposerContext : MessageComposerContext {
|
||||
override var composerMode: MessageComposerMode by mutableStateOf(MessageComposerMode.Normal)
|
||||
internal set
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredHeightIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.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.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.IconColorButton
|
||||
import io.element.android.libraries.designsystem.theme.components.IconColorButtonStyle
|
||||
|
||||
@Composable
|
||||
internal fun DisabledComposerView(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.padding(3.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconColorButton(
|
||||
onClick = {},
|
||||
imageVector = CompoundIcons.Plus(),
|
||||
contentDescription = null,
|
||||
iconColorButtonStyle = IconColorButtonStyle.Disabled,
|
||||
)
|
||||
|
||||
val bgColor = ElementTheme.colors.bgCanvasDisabled
|
||||
val borderColor = ElementTheme.colors.borderDisabled
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(21.dp))
|
||||
.border(0.5.dp, borderColor, RoundedCornerShape(21.dp))
|
||||
.background(color = bgColor)
|
||||
.size(42.dp)
|
||||
.requiredHeightIn(min = 42.dp)
|
||||
.weight(1f),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
IconButton(
|
||||
modifier = Modifier
|
||||
.padding(start = 2.dp)
|
||||
.size(48.dp),
|
||||
enabled = false,
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(30.dp),
|
||||
imageVector = CompoundIcons.SendSolid(),
|
||||
contentDescription = "",
|
||||
tint = ElementTheme.colors.iconQuaternary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun DisabledComposerViewPreview() = ElementPreview {
|
||||
Column {
|
||||
DisabledComposerView(
|
||||
modifier = Modifier.height(IntrinsicSize.Min),
|
||||
)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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.messagecomposer
|
||||
|
||||
import android.net.Uri
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
|
||||
sealed interface MessageComposerEvent {
|
||||
data object ToggleFullScreenState : MessageComposerEvent
|
||||
data object SendMessage : MessageComposerEvent
|
||||
data class SendUri(val uri: Uri) : MessageComposerEvent
|
||||
data object CloseSpecialMode : MessageComposerEvent
|
||||
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvent
|
||||
data object AddAttachment : MessageComposerEvent
|
||||
data object DismissAttachmentMenu : MessageComposerEvent
|
||||
sealed interface PickAttachmentSource : MessageComposerEvent {
|
||||
data object FromGallery : PickAttachmentSource
|
||||
data object FromFiles : PickAttachmentSource
|
||||
data object PhotoFromCamera : PickAttachmentSource
|
||||
data object VideoFromCamera : PickAttachmentSource
|
||||
data object Location : PickAttachmentSource
|
||||
data object Poll : PickAttachmentSource
|
||||
}
|
||||
|
||||
data class ToggleTextFormatting(val enabled: Boolean) : MessageComposerEvent
|
||||
data class Error(val error: Throwable) : MessageComposerEvent
|
||||
data class TypingNotice(val isTyping: Boolean) : MessageComposerEvent
|
||||
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvent
|
||||
data class InsertSuggestion(val resolvedSuggestion: ResolvedSuggestion) : MessageComposerEvent
|
||||
data object SaveDraft : MessageComposerEvent
|
||||
}
|
||||
+766
@@ -0,0 +1,766 @@
|
||||
/*
|
||||
* 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.messagecomposer
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.Uri
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.Composer
|
||||
import im.vector.app.features.analytics.plan.Interaction
|
||||
import io.element.android.features.location.api.LocationService
|
||||
import io.element.android.features.messages.impl.MessagesNavigator
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
||||
import io.element.android.features.messages.impl.draft.ComposerDraftService
|
||||
import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestionsDataSource
|
||||
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
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.PermalinkBuilder
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
|
||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
|
||||
import io.element.android.libraries.matrix.api.room.getDirectRoomMember
|
||||
import io.element.android.libraries.matrix.api.room.isDm
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.map
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import io.element.android.wysiwyg.display.TextDisplay
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
|
||||
|
||||
@AssistedInject
|
||||
class MessageComposerPresenter(
|
||||
@Assisted private val navigator: MessagesNavigator,
|
||||
@Assisted private val timelineController: TimelineController,
|
||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||
private val room: JoinedRoom,
|
||||
private val mediaPickerProvider: PickerProvider,
|
||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||
private val localMediaFactory: LocalMediaFactory,
|
||||
mediaSenderFactory: MediaSenderFactory,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val locationService: LocationService,
|
||||
private val messageComposerContext: DefaultMessageComposerContext,
|
||||
private val richTextEditorStateFactory: RichTextEditorStateFactory,
|
||||
private val roomAliasSuggestionsDataSource: RoomAliasSuggestionsDataSource,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val permalinkBuilder: PermalinkBuilder,
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||
private val draftService: ComposerDraftService,
|
||||
private val mentionSpanProvider: MentionSpanProvider,
|
||||
private val pillificationHelper: TextPillificationHelper,
|
||||
private val suggestionsProcessor: SuggestionsProcessor,
|
||||
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||
private val notificationConversationService: NotificationConversationService,
|
||||
) : Presenter<MessageComposerState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(timelineController: TimelineController, navigator: MessagesNavigator): MessageComposerPresenter
|
||||
}
|
||||
|
||||
private val mediaSender = mediaSenderFactory.create(timelineMode = timelineController.mainTimelineMode())
|
||||
|
||||
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
|
||||
private var pendingEvent: MessageComposerEvent? = null
|
||||
private val suggestionSearchTrigger = MutableStateFlow<Suggestion?>(null)
|
||||
|
||||
// Used to disable some UI related elements in tests
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal var isTesting: Boolean = false
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal var showTextFormatting: Boolean by mutableStateOf(false)
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
@Composable
|
||||
override fun present(): MessageComposerState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
|
||||
val roomInfo by room.roomInfoFlow.collectAsState()
|
||||
|
||||
val richTextEditorState = richTextEditorStateFactory.remember()
|
||||
if (isTesting) {
|
||||
richTextEditorState.isReadyToProcessActions = true
|
||||
}
|
||||
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
|
||||
|
||||
val cameraPermissionState = cameraPermissionPresenter.present()
|
||||
|
||||
val canShareLocation = remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
canShareLocation.value = locationService.isServiceAvailable()
|
||||
}
|
||||
|
||||
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
|
||||
handlePickedMedia(uri, mimeType)
|
||||
}
|
||||
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType ->
|
||||
handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream)
|
||||
}
|
||||
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
|
||||
handlePickedMedia(uri, MimeTypes.Jpeg)
|
||||
}
|
||||
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
|
||||
handlePickedMedia(uri, MimeTypes.Mp4)
|
||||
}
|
||||
val isFullScreen = rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
|
||||
|
||||
val sendTypingNotifications by remember {
|
||||
sessionPreferencesStore.isSendTypingNotificationsEnabled()
|
||||
}.collectAsState(initial = true)
|
||||
|
||||
LaunchedEffect(cameraPermissionState.permissionGranted) {
|
||||
if (cameraPermissionState.permissionGranted) {
|
||||
when (pendingEvent) {
|
||||
is MessageComposerEvent.PickAttachmentSource.PhotoFromCamera -> cameraPhotoPicker.launch()
|
||||
is MessageComposerEvent.PickAttachmentSource.VideoFromCamera -> cameraVideoPicker.launch()
|
||||
else -> Unit
|
||||
}
|
||||
pendingEvent = null
|
||||
}
|
||||
}
|
||||
|
||||
val suggestions = remember { mutableStateListOf<ResolvedSuggestion>() }
|
||||
ResolveSuggestionsEffect(suggestions)
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
// Declare that the user is not typing anymore when the composer is disposed
|
||||
onDispose {
|
||||
sessionCoroutineScope.launch {
|
||||
if (sendTypingNotifications) {
|
||||
room.typingNotice(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val textEditorState by rememberUpdatedState(
|
||||
if (showTextFormatting) {
|
||||
TextEditorState.Rich(richTextEditorState, roomInfo.isEncrypted == true)
|
||||
} else {
|
||||
TextEditorState.Markdown(markdownTextEditorState, roomInfo.isEncrypted == true)
|
||||
}
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val draft = draftService.loadDraft(
|
||||
roomId = room.roomId,
|
||||
// TODO support threads in composer
|
||||
threadRoot = null,
|
||||
isVolatile = false
|
||||
)
|
||||
if (draft != null) {
|
||||
applyDraft(draft, markdownTextEditorState, richTextEditorState)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleEvent(event: MessageComposerEvent) {
|
||||
when (event) {
|
||||
MessageComposerEvent.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
|
||||
MessageComposerEvent.CloseSpecialMode -> {
|
||||
if (messageComposerContext.composerMode.isEditing) {
|
||||
localCoroutineScope.launch {
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = true)
|
||||
}
|
||||
} else {
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||
}
|
||||
}
|
||||
is MessageComposerEvent.SendMessage -> {
|
||||
sessionCoroutineScope.sendMessage(
|
||||
markdownTextEditorState = markdownTextEditorState,
|
||||
richTextEditorState = richTextEditorState,
|
||||
)
|
||||
}
|
||||
is MessageComposerEvent.SendUri -> {
|
||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||
sessionCoroutineScope.sendAttachment(
|
||||
attachment = Attachment.Media(
|
||||
localMedia = localMediaFactory.createFromUri(
|
||||
uri = event.uri,
|
||||
mimeType = null,
|
||||
name = null,
|
||||
formattedFileSize = null
|
||||
),
|
||||
),
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
|
||||
// Reset composer since the attachment has been sent
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||
}
|
||||
is MessageComposerEvent.SetMode -> {
|
||||
localCoroutineScope.setMode(event.composerMode, markdownTextEditorState, richTextEditorState)
|
||||
}
|
||||
MessageComposerEvent.AddAttachment -> localCoroutineScope.launch {
|
||||
showAttachmentSourcePicker = true
|
||||
}
|
||||
MessageComposerEvent.DismissAttachmentMenu -> showAttachmentSourcePicker = false
|
||||
MessageComposerEvent.PickAttachmentSource.FromGallery -> localCoroutineScope.launch {
|
||||
showAttachmentSourcePicker = false
|
||||
galleryMediaPicker.launch()
|
||||
}
|
||||
MessageComposerEvent.PickAttachmentSource.FromFiles -> localCoroutineScope.launch {
|
||||
showAttachmentSourcePicker = false
|
||||
filesPicker.launch()
|
||||
}
|
||||
MessageComposerEvent.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launch {
|
||||
showAttachmentSourcePicker = false
|
||||
if (cameraPermissionState.permissionGranted) {
|
||||
cameraPhotoPicker.launch()
|
||||
} else {
|
||||
pendingEvent = event
|
||||
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
|
||||
}
|
||||
}
|
||||
MessageComposerEvent.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launch {
|
||||
showAttachmentSourcePicker = false
|
||||
if (cameraPermissionState.permissionGranted) {
|
||||
cameraVideoPicker.launch()
|
||||
} else {
|
||||
pendingEvent = event
|
||||
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
|
||||
}
|
||||
}
|
||||
MessageComposerEvent.PickAttachmentSource.Location -> {
|
||||
showAttachmentSourcePicker = false
|
||||
// Navigation to the location picker screen is done at the view layer
|
||||
}
|
||||
MessageComposerEvent.PickAttachmentSource.Poll -> {
|
||||
showAttachmentSourcePicker = false
|
||||
// Navigation to the create poll screen is done at the view layer
|
||||
}
|
||||
is MessageComposerEvent.ToggleTextFormatting -> {
|
||||
showAttachmentSourcePicker = false
|
||||
localCoroutineScope.toggleTextFormatting(event.enabled, markdownTextEditorState, richTextEditorState)
|
||||
}
|
||||
is MessageComposerEvent.Error -> {
|
||||
analyticsService.trackError(event.error)
|
||||
}
|
||||
is MessageComposerEvent.TypingNotice -> {
|
||||
if (sendTypingNotifications) {
|
||||
localCoroutineScope.launch {
|
||||
room.typingNotice(event.isTyping)
|
||||
}
|
||||
}
|
||||
}
|
||||
is MessageComposerEvent.SuggestionReceived -> {
|
||||
suggestionSearchTrigger.value = event.suggestion
|
||||
}
|
||||
is MessageComposerEvent.InsertSuggestion -> {
|
||||
localCoroutineScope.launch {
|
||||
if (showTextFormatting) {
|
||||
when (val suggestion = event.resolvedSuggestion) {
|
||||
is ResolvedSuggestion.AtRoom -> {
|
||||
richTextEditorState.insertAtRoomMentionAtSuggestion()
|
||||
}
|
||||
is ResolvedSuggestion.Member -> {
|
||||
val text = suggestion.roomMember.userId.value
|
||||
val link = permalinkBuilder.permalinkForUser(suggestion.roomMember.userId).getOrNull() ?: return@launch
|
||||
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
|
||||
}
|
||||
is ResolvedSuggestion.Alias -> {
|
||||
val text = suggestion.roomAlias.value
|
||||
val link = permalinkBuilder.permalinkForRoomAlias(suggestion.roomAlias).getOrNull() ?: return@launch
|
||||
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
|
||||
}
|
||||
}
|
||||
} else if (markdownTextEditorState.currentSuggestion != null) {
|
||||
markdownTextEditorState.insertSuggestion(
|
||||
resolvedSuggestion = event.resolvedSuggestion,
|
||||
mentionSpanProvider = mentionSpanProvider,
|
||||
)
|
||||
suggestionSearchTrigger.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
MessageComposerEvent.SaveDraft -> {
|
||||
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
|
||||
sessionCoroutineScope.updateDraft(draft, isVolatile = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val resolveMentionDisplay = remember {
|
||||
{ text: String, url: String ->
|
||||
val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url)
|
||||
if (mentionSpan != null) {
|
||||
TextDisplay.Custom(mentionSpan)
|
||||
} else {
|
||||
TextDisplay.Plain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val resolveAtRoomMentionDisplay = remember {
|
||||
{
|
||||
val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan()
|
||||
TextDisplay.Custom(mentionSpan)
|
||||
}
|
||||
}
|
||||
|
||||
return MessageComposerState(
|
||||
textEditorState = textEditorState,
|
||||
isFullScreen = isFullScreen.value,
|
||||
mode = messageComposerContext.composerMode,
|
||||
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||
showTextFormatting = showTextFormatting,
|
||||
canShareLocation = canShareLocation.value,
|
||||
suggestions = suggestions.toImmutableList(),
|
||||
resolveMentionDisplay = resolveMentionDisplay,
|
||||
resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@Composable
|
||||
private fun ResolveSuggestionsEffect(
|
||||
suggestions: SnapshotStateList<ResolvedSuggestion>,
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
val currentUserId = room.sessionId
|
||||
|
||||
suspend fun canSendRoomMention(): Boolean {
|
||||
val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false)
|
||||
return !room.isDm() && userCanSendAtRoom
|
||||
}
|
||||
|
||||
// This will trigger a search immediately when `@` is typed
|
||||
val mentionStartTrigger = suggestionSearchTrigger.filter { it?.text.isNullOrEmpty() }
|
||||
// This will start a search when the user changes the text after the `@` with a debounce to prevent too much wasted work
|
||||
val mentionCompletionTrigger = suggestionSearchTrigger.debounce(0.3.seconds).filter { !it?.text.isNullOrEmpty() }
|
||||
|
||||
val mentionTriggerFlow = merge(mentionStartTrigger, mentionCompletionTrigger)
|
||||
|
||||
val roomAliasSuggestionsFlow = roomAliasSuggestionsDataSource
|
||||
.getAllRoomAliasSuggestions()
|
||||
.stateIn(this, SharingStarted.Lazily, emptyList())
|
||||
|
||||
combine(mentionTriggerFlow, room.membersStateFlow, roomAliasSuggestionsFlow) { suggestion, roomMembersState, roomAliasSuggestions ->
|
||||
val result = suggestionsProcessor.process(
|
||||
suggestion = suggestion,
|
||||
roomMembersState = roomMembersState,
|
||||
roomAliasSuggestions = roomAliasSuggestions,
|
||||
currentUserId = currentUserId,
|
||||
canSendRoomMention = ::canSendRoomMention,
|
||||
)
|
||||
suggestions.clear()
|
||||
suggestions.addAll(result)
|
||||
}
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.sendMessage(
|
||||
markdownTextEditorState: MarkdownTextEditorState,
|
||||
richTextEditorState: RichTextEditorState,
|
||||
) = launch {
|
||||
val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = true)
|
||||
val capturedMode = messageComposerContext.composerMode
|
||||
// Reset composer right away
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
|
||||
when (capturedMode) {
|
||||
is MessageComposerMode.Attachment,
|
||||
is MessageComposerMode.Normal -> timelineController.invokeOnCurrentTimeline {
|
||||
sendMessage(
|
||||
body = message.markdown,
|
||||
htmlBody = message.html,
|
||||
intentionalMentions = message.intentionalMentions
|
||||
)
|
||||
}
|
||||
is MessageComposerMode.Edit -> {
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
// First try to edit the message in the current timeline
|
||||
editMessage(capturedMode.eventOrTransactionId, message.markdown, message.html, message.intentionalMentions)
|
||||
.onFailure { cause ->
|
||||
val eventId = capturedMode.eventOrTransactionId.eventId
|
||||
if (cause is TimelineException.EventNotFound && eventId != null) {
|
||||
// if the event is not found in the timeline, try to edit the message directly
|
||||
room.editMessage(eventId, message.markdown, message.html, message.intentionalMentions)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is MessageComposerMode.EditCaption -> {
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
editCaption(
|
||||
capturedMode.eventOrTransactionId,
|
||||
caption = message.markdown,
|
||||
formattedCaption = message.html
|
||||
)
|
||||
}
|
||||
}
|
||||
is MessageComposerMode.Reply -> {
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
with(capturedMode) {
|
||||
replyMessage(
|
||||
body = message.markdown,
|
||||
htmlBody = message.html,
|
||||
intentionalMentions = message.intentionalMentions,
|
||||
repliedToEventId = eventId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val roomInfo = room.info()
|
||||
val roomMembers = room.membersStateFlow.value
|
||||
|
||||
notificationConversationService.onSendMessage(
|
||||
sessionId = room.sessionId,
|
||||
roomId = roomInfo.id,
|
||||
roomName = roomInfo.name ?: roomInfo.id.value,
|
||||
roomIsDirect = roomInfo.isDm,
|
||||
roomAvatarUrl = roomInfo.avatarUrl ?: roomMembers.getDirectRoomMember(roomInfo = roomInfo, sessionId = room.sessionId)?.avatarUrl,
|
||||
)
|
||||
|
||||
analyticsService.capture(
|
||||
Composer(
|
||||
inThread = capturedMode.inThread,
|
||||
isEditing = capturedMode.isEditing,
|
||||
isReply = capturedMode.isReply,
|
||||
// Set proper type when we'll be sending other types of messages.
|
||||
messageType = Composer.MessageType.Text,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.sendAttachment(
|
||||
attachment: Attachment,
|
||||
inReplyToEventId: EventId?,
|
||||
) = when (attachment) {
|
||||
is Attachment.Media -> {
|
||||
launch {
|
||||
sendMedia(
|
||||
uri = attachment.localMedia.uri,
|
||||
mimeType = attachment.localMedia.info.mimeType,
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePickedMedia(
|
||||
uri: Uri?,
|
||||
mimeType: String? = null,
|
||||
) {
|
||||
uri ?: return
|
||||
val localMedia = localMediaFactory.createFromUri(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
name = null,
|
||||
formattedFileSize = null
|
||||
)
|
||||
val mediaAttachment = Attachment.Media(localMedia)
|
||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||
navigator.navigateToPreviewAttachments(persistentListOf(mediaAttachment), inReplyToEventId)
|
||||
|
||||
// Reset composer since the attachment will be sent in a separate flow
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||
}
|
||||
|
||||
private suspend fun sendMedia(
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
inReplyToEventId: EventId?,
|
||||
) = runCatchingExceptions {
|
||||
mediaSender.sendMedia(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
).getOrThrow()
|
||||
}
|
||||
.onFailure { cause ->
|
||||
Timber.e(cause, "Failed to send attachment")
|
||||
if (cause is CancellationException) {
|
||||
throw cause
|
||||
} else {
|
||||
val snackbarMessage = SnackbarMessage(sendAttachmentError(cause))
|
||||
snackbarDispatcher.post(snackbarMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.updateDraft(
|
||||
draft: ComposerDraft?,
|
||||
isVolatile: Boolean,
|
||||
) = launch {
|
||||
draftService.updateDraft(
|
||||
roomId = room.roomId,
|
||||
draft = draft,
|
||||
isVolatile = isVolatile,
|
||||
// TODO support threads in composer
|
||||
threadRoot = null,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun applyDraft(
|
||||
draft: ComposerDraft,
|
||||
markdownTextEditorState: MarkdownTextEditorState,
|
||||
richTextEditorState: RichTextEditorState,
|
||||
) {
|
||||
val htmlText = draft.htmlText
|
||||
val markdownText = draft.plainText
|
||||
if (htmlText != null) {
|
||||
showTextFormatting = true
|
||||
setText(htmlText, markdownTextEditorState, richTextEditorState, requestFocus = true)
|
||||
} else {
|
||||
showTextFormatting = false
|
||||
setText(markdownText, markdownTextEditorState, richTextEditorState, requestFocus = true)
|
||||
}
|
||||
when (val draftType = draft.draftType) {
|
||||
ComposerDraftType.NewMessage -> messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||
is ComposerDraftType.Edit -> messageComposerContext.composerMode = MessageComposerMode.Edit(
|
||||
eventOrTransactionId = draftType.eventId.toEventOrTransactionId(),
|
||||
content = htmlText ?: markdownText
|
||||
)
|
||||
is ComposerDraftType.Reply -> {
|
||||
messageComposerContext.composerMode = MessageComposerMode.Reply(
|
||||
replyToDetails = InReplyToDetails.Loading(draftType.eventId),
|
||||
// I guess it's fine to always render the image when restoring a draft
|
||||
hideImage = false
|
||||
)
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
val replyToDetails = loadReplyDetails(draftType.eventId).map(permalinkParser)
|
||||
messageComposerContext.composerMode = MessageComposerMode.Reply(
|
||||
replyToDetails = replyToDetails,
|
||||
// I guess it's fine to always render the image when restoring a draft
|
||||
hideImage = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDraftFromState(
|
||||
markdownTextEditorState: MarkdownTextEditorState,
|
||||
richTextEditorState: RichTextEditorState,
|
||||
): ComposerDraft? {
|
||||
val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = false)
|
||||
val draftType = when (val mode = messageComposerContext.composerMode) {
|
||||
is MessageComposerMode.Attachment,
|
||||
is MessageComposerMode.Normal -> ComposerDraftType.NewMessage
|
||||
is MessageComposerMode.Edit -> {
|
||||
mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) }
|
||||
}
|
||||
is MessageComposerMode.Reply -> ComposerDraftType.Reply(mode.eventId)
|
||||
is MessageComposerMode.EditCaption -> {
|
||||
// TODO Need a new type to save caption in the SDK
|
||||
null
|
||||
}
|
||||
}
|
||||
return if (draftType == null || message.markdown.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
ComposerDraft(
|
||||
draftType = draftType,
|
||||
htmlText = message.html,
|
||||
plainText = message.markdown,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentComposerMessage(
|
||||
markdownTextEditorState: MarkdownTextEditorState,
|
||||
richTextEditorState: RichTextEditorState,
|
||||
withMentions: Boolean,
|
||||
): Message {
|
||||
return if (showTextFormatting) {
|
||||
val html = richTextEditorState.messageHtml
|
||||
val markdown = richTextEditorState.messageMarkdown
|
||||
val mentions = richTextEditorState.mentionsState
|
||||
.takeIf { withMentions }
|
||||
?.let { state ->
|
||||
buildList {
|
||||
if (state.hasAtRoomMention) {
|
||||
add(IntentionalMention.Room)
|
||||
}
|
||||
for (userId in state.userIds) {
|
||||
add(IntentionalMention.User(UserId(userId)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.orEmpty()
|
||||
Message(html = html, markdown = markdown, intentionalMentions = mentions)
|
||||
} else {
|
||||
val markdown = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
|
||||
val mentions = if (withMentions) {
|
||||
markdownTextEditorState.getMentions()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
Message(html = null, markdown = markdown, intentionalMentions = mentions)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.toggleTextFormatting(
|
||||
enabled: Boolean,
|
||||
markdownTextEditorState: MarkdownTextEditorState,
|
||||
richTextEditorState: RichTextEditorState
|
||||
) = launch {
|
||||
showTextFormatting = enabled
|
||||
if (showTextFormatting) {
|
||||
val markdown = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
|
||||
richTextEditorState.setMarkdown(markdown)
|
||||
richTextEditorState.requestFocus()
|
||||
analyticsService.captureInteraction(Interaction.Name.MobileRoomComposerFormattingEnabled)
|
||||
} else {
|
||||
val markdown = richTextEditorState.messageMarkdown
|
||||
val markdownWithMentions = pillificationHelper.pillify(markdown, false)
|
||||
markdownTextEditorState.text.update(markdownWithMentions, true)
|
||||
// Give some time for the focus of the previous editor to be cleared
|
||||
delay(100)
|
||||
markdownTextEditorState.requestFocusAction()
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.setMode(
|
||||
newComposerMode: MessageComposerMode,
|
||||
markdownTextEditorState: MarkdownTextEditorState,
|
||||
richTextEditorState: RichTextEditorState,
|
||||
) = launch {
|
||||
val currentComposerMode = messageComposerContext.composerMode
|
||||
when (newComposerMode) {
|
||||
is MessageComposerMode.Edit -> {
|
||||
if (currentComposerMode.isEditing.not()) {
|
||||
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
|
||||
updateDraft(draft, isVolatile = true).join()
|
||||
}
|
||||
setText(newComposerMode.content, markdownTextEditorState, richTextEditorState)
|
||||
}
|
||||
is MessageComposerMode.EditCaption -> {
|
||||
if (currentComposerMode.isEditing.not()) {
|
||||
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
|
||||
updateDraft(draft, isVolatile = true).join()
|
||||
}
|
||||
setText(newComposerMode.content, markdownTextEditorState, richTextEditorState)
|
||||
}
|
||||
else -> {
|
||||
// When coming from edit, just clear the composer as it'd be weird to reset a volatile draft in this scenario.
|
||||
if (currentComposerMode.isEditing) {
|
||||
setText("", markdownTextEditorState, richTextEditorState)
|
||||
}
|
||||
}
|
||||
}
|
||||
messageComposerContext.composerMode = newComposerMode
|
||||
}
|
||||
|
||||
private suspend fun resetComposer(
|
||||
markdownTextEditorState: MarkdownTextEditorState,
|
||||
richTextEditorState: RichTextEditorState,
|
||||
fromEdit: Boolean,
|
||||
) {
|
||||
// Use the volatile draft only when coming from edit mode otherwise.
|
||||
val draft = draftService.loadDraft(
|
||||
roomId = room.roomId,
|
||||
// TODO support threads in composer
|
||||
threadRoot = null,
|
||||
isVolatile = true
|
||||
).takeIf { fromEdit }
|
||||
if (draft != null) {
|
||||
applyDraft(draft, markdownTextEditorState, richTextEditorState)
|
||||
} else {
|
||||
setText("", markdownTextEditorState, richTextEditorState)
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setText(
|
||||
content: String,
|
||||
markdownTextEditorState: MarkdownTextEditorState,
|
||||
richTextEditorState: RichTextEditorState,
|
||||
requestFocus: Boolean = false,
|
||||
) {
|
||||
if (showTextFormatting) {
|
||||
richTextEditorState.setHtml(content)
|
||||
if (requestFocus) {
|
||||
richTextEditorState.requestFocus()
|
||||
}
|
||||
} else {
|
||||
if (content.isEmpty()) {
|
||||
markdownTextEditorState.selection = IntRange.EMPTY
|
||||
}
|
||||
val pillifiedContent = pillificationHelper.pillify(content, false)
|
||||
markdownTextEditorState.text.update(pillifiedContent, true)
|
||||
if (requestFocus) {
|
||||
markdownTextEditorState.requestFocusAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2022-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.messagecomposer
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.wysiwyg.display.TextDisplay
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Stable
|
||||
data class MessageComposerState(
|
||||
val textEditorState: TextEditorState,
|
||||
val isFullScreen: Boolean,
|
||||
val mode: MessageComposerMode,
|
||||
val showAttachmentSourcePicker: Boolean,
|
||||
val showTextFormatting: Boolean,
|
||||
val canShareLocation: Boolean,
|
||||
val suggestions: ImmutableList<ResolvedSuggestion>,
|
||||
val resolveMentionDisplay: (String, String) -> TextDisplay,
|
||||
val resolveAtRoomMentionDisplay: () -> TextDisplay,
|
||||
val eventSink: (MessageComposerEvent) -> Unit,
|
||||
)
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2022-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.messagecomposer
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
|
||||
import io.element.android.wysiwyg.display.TextDisplay
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
open class MessageComposerStateProvider : PreviewParameterProvider<MessageComposerState> {
|
||||
override val values: Sequence<MessageComposerState>
|
||||
get() = sequenceOf(
|
||||
aMessageComposerState(),
|
||||
)
|
||||
}
|
||||
|
||||
fun aMessageComposerState(
|
||||
textEditorState: TextEditorState = aTextEditorStateRich(),
|
||||
isFullScreen: Boolean = false,
|
||||
mode: MessageComposerMode = MessageComposerMode.Normal,
|
||||
showTextFormatting: Boolean = false,
|
||||
showAttachmentSourcePicker: Boolean = false,
|
||||
canShareLocation: Boolean = true,
|
||||
suggestions: ImmutableList<ResolvedSuggestion> = persistentListOf(),
|
||||
eventSink: (MessageComposerEvent) -> Unit = {},
|
||||
) = MessageComposerState(
|
||||
textEditorState = textEditorState,
|
||||
isFullScreen = isFullScreen,
|
||||
mode = mode,
|
||||
showTextFormatting = showTextFormatting,
|
||||
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||
canShareLocation = canShareLocation,
|
||||
suggestions = suggestions,
|
||||
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
|
||||
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
|
||||
eventSink = eventSink,
|
||||
)
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* 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.messagecomposer
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
|
||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
|
||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerStateProvider
|
||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.textcomposer.TextComposer
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
internal fun MessageComposerView(
|
||||
state: MessageComposerState,
|
||||
voiceMessageState: VoiceMessageComposerState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val view = LocalView.current
|
||||
fun sendMessage() {
|
||||
state.eventSink(MessageComposerEvent.SendMessage)
|
||||
}
|
||||
|
||||
fun sendUri(uri: Uri) {
|
||||
state.eventSink(MessageComposerEvent.SendUri(uri))
|
||||
}
|
||||
|
||||
fun onAddAttachment() {
|
||||
state.eventSink(MessageComposerEvent.AddAttachment)
|
||||
}
|
||||
|
||||
fun onCloseSpecialMode() {
|
||||
state.eventSink(MessageComposerEvent.CloseSpecialMode)
|
||||
}
|
||||
|
||||
fun onDismissTextFormatting() {
|
||||
view.clearFocus()
|
||||
state.eventSink(MessageComposerEvent.ToggleTextFormatting(enabled = false))
|
||||
}
|
||||
|
||||
fun onSuggestionReceived(suggestion: Suggestion?) {
|
||||
state.eventSink(MessageComposerEvent.SuggestionReceived(suggestion))
|
||||
}
|
||||
|
||||
fun onError(error: Throwable) {
|
||||
state.eventSink(MessageComposerEvent.Error(error))
|
||||
}
|
||||
|
||||
fun onTyping(typing: Boolean) {
|
||||
state.eventSink(MessageComposerEvent.TypingNotice(typing))
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
fun onRequestFocus() {
|
||||
coroutineScope.launch {
|
||||
state.textEditorState.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
val onVoiceRecorderEvent = { press: VoiceMessageRecorderEvent ->
|
||||
voiceMessageState.eventSink(VoiceMessageComposerEvent.RecorderEvent(press))
|
||||
}
|
||||
|
||||
val onSendVoiceMessage = {
|
||||
voiceMessageState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
|
||||
}
|
||||
|
||||
val onDeleteVoiceMessage = {
|
||||
voiceMessageState.eventSink(VoiceMessageComposerEvent.DeleteVoiceMessage)
|
||||
}
|
||||
|
||||
val onVoicePlayerEvent = { event: VoiceMessagePlayerEvent ->
|
||||
voiceMessageState.eventSink(VoiceMessageComposerEvent.PlayerEvent(event))
|
||||
}
|
||||
|
||||
TextComposer(
|
||||
modifier = modifier,
|
||||
state = state.textEditorState,
|
||||
voiceMessageState = voiceMessageState.voiceMessageState,
|
||||
onRequestFocus = ::onRequestFocus,
|
||||
onSendMessage = ::sendMessage,
|
||||
composerMode = state.mode,
|
||||
showTextFormatting = state.showTextFormatting,
|
||||
onResetComposerMode = ::onCloseSpecialMode,
|
||||
onAddAttachment = ::onAddAttachment,
|
||||
onDismissTextFormatting = ::onDismissTextFormatting,
|
||||
onVoiceRecorderEvent = onVoiceRecorderEvent,
|
||||
onVoicePlayerEvent = onVoicePlayerEvent,
|
||||
onSendVoiceMessage = onSendVoiceMessage,
|
||||
onDeleteVoiceMessage = onDeleteVoiceMessage,
|
||||
onReceiveSuggestion = ::onSuggestionReceived,
|
||||
resolveMentionDisplay = state.resolveMentionDisplay,
|
||||
resolveAtRoomMentionDisplay = state.resolveAtRoomMentionDisplay,
|
||||
onError = ::onError,
|
||||
onTyping = ::onTyping,
|
||||
onSelectRichContent = ::sendUri,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MessageComposerViewPreview(
|
||||
@PreviewParameter(MessageComposerStateProvider::class) state: MessageComposerState,
|
||||
) = ElementPreview {
|
||||
Column {
|
||||
MessageComposerView(
|
||||
modifier = Modifier.height(IntrinsicSize.Min),
|
||||
state = state,
|
||||
voiceMessageState = aVoiceMessageComposerState(),
|
||||
)
|
||||
MessageComposerView(
|
||||
modifier = Modifier.height(200.dp),
|
||||
state = state,
|
||||
voiceMessageState = aVoiceMessageComposerState(),
|
||||
)
|
||||
DisabledComposerView()
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MessageComposerViewVoicePreview(
|
||||
@PreviewParameter(VoiceMessageComposerStateProvider::class) state: VoiceMessageComposerState,
|
||||
) = ElementPreview {
|
||||
Column {
|
||||
MessageComposerView(
|
||||
modifier = Modifier.height(IntrinsicSize.Min),
|
||||
state = aMessageComposerState(),
|
||||
voiceMessageState = state,
|
||||
)
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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.messagecomposer
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import io.element.android.wysiwyg.compose.rememberRichTextEditorState
|
||||
|
||||
interface RichTextEditorStateFactory {
|
||||
@Composable
|
||||
fun remember(): RichTextEditorState
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultRichTextEditorStateFactory : RichTextEditorStateFactory {
|
||||
@Composable
|
||||
override fun remember(): RichTextEditorState {
|
||||
return rememberRichTextEditorState()
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.messagecomposer.suggestions
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
data class RoomAliasSuggestion(
|
||||
val roomAlias: RoomAlias,
|
||||
val roomId: RoomId,
|
||||
val roomName: String?,
|
||||
val roomAvatarUrl: String?,
|
||||
)
|
||||
|
||||
interface RoomAliasSuggestionsDataSource {
|
||||
fun getAllRoomAliasSuggestions(): Flow<List<RoomAliasSuggestion>>
|
||||
}
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultRoomAliasSuggestionsDataSource(
|
||||
private val roomListService: RoomListService,
|
||||
) : RoomAliasSuggestionsDataSource {
|
||||
override fun getAllRoomAliasSuggestions(): Flow<List<RoomAliasSuggestion>> {
|
||||
return roomListService
|
||||
.allRooms
|
||||
.summaries
|
||||
.map { roomSummaries ->
|
||||
roomSummaries
|
||||
.mapNotNull { roomSummary ->
|
||||
roomSummary.info.canonicalAlias?.let { roomAlias ->
|
||||
RoomAliasSuggestion(
|
||||
roomAlias = roomAlias,
|
||||
roomId = roomSummary.roomId,
|
||||
roomName = roomSummary.info.name,
|
||||
roomAvatarUrl = roomSummary.info.avatarUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* 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.messagecomposer.suggestions
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.R
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Composable
|
||||
fun SuggestionsPickerView(
|
||||
roomId: RoomId,
|
||||
roomName: String?,
|
||||
roomAvatarData: AvatarData,
|
||||
suggestions: ImmutableList<ResolvedSuggestion>,
|
||||
onSelectSuggestion: (ResolvedSuggestion) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
items(
|
||||
suggestions,
|
||||
key = { suggestion ->
|
||||
when (suggestion) {
|
||||
is ResolvedSuggestion.AtRoom -> "@room"
|
||||
is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
|
||||
is ResolvedSuggestion.Alias -> suggestion.roomId.value
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(modifier = Modifier.fillParentMaxWidth()) {
|
||||
SuggestionItemView(
|
||||
suggestion = it,
|
||||
roomId = roomId.value,
|
||||
roomName = roomName,
|
||||
roomAvatar = roomAvatarData,
|
||||
onSelectSuggestion = onSelectSuggestion,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuggestionItemView(
|
||||
suggestion: ResolvedSuggestion,
|
||||
roomId: String,
|
||||
roomName: String?,
|
||||
roomAvatar: AvatarData?,
|
||||
onSelectSuggestion: (ResolvedSuggestion) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.clickable { onSelectSuggestion(suggestion) },
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
val avatarSize = AvatarSize.Suggestion
|
||||
val avatarData = when (suggestion) {
|
||||
is ResolvedSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
|
||||
is ResolvedSuggestion.Member -> suggestion.roomMember.getAvatarData(avatarSize)
|
||||
is ResolvedSuggestion.Alias -> suggestion.getAvatarData(avatarSize)
|
||||
}
|
||||
val avatarType = when (suggestion) {
|
||||
is ResolvedSuggestion.Alias -> AvatarType.Room()
|
||||
ResolvedSuggestion.AtRoom,
|
||||
is ResolvedSuggestion.Member -> AvatarType.User
|
||||
}
|
||||
val title = when (suggestion) {
|
||||
is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title)
|
||||
is ResolvedSuggestion.Member -> suggestion.roomMember.displayName
|
||||
is ResolvedSuggestion.Alias -> suggestion.roomName
|
||||
}
|
||||
val subtitle = when (suggestion) {
|
||||
is ResolvedSuggestion.AtRoom -> "@room"
|
||||
is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
|
||||
is ResolvedSuggestion.Alias -> suggestion.roomAlias.value
|
||||
}
|
||||
Avatar(
|
||||
avatarData = avatarData,
|
||||
avatarType = avatarType,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = 16.dp, top = 8.dp, bottom = 8.dp)
|
||||
.align(Alignment.CenterVertically),
|
||||
) {
|
||||
title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun SuggestionsPickerViewPreview() {
|
||||
ElementPreview {
|
||||
val roomMember = RoomMember(
|
||||
userId = UserId("@alice:server.org"),
|
||||
displayName = null,
|
||||
avatarUrl = null,
|
||||
membership = RoomMembershipState.JOIN,
|
||||
isNameAmbiguous = false,
|
||||
powerLevel = 0L,
|
||||
isIgnored = false,
|
||||
role = RoomMember.Role.User,
|
||||
membershipChangeReason = null,
|
||||
)
|
||||
val anAlias = remember { RoomAlias("#room:domain.org") }
|
||||
SuggestionsPickerView(
|
||||
roomId = RoomId("!room:matrix.org"),
|
||||
roomName = "Room",
|
||||
roomAvatarData = anAvatarData(),
|
||||
suggestions = persistentListOf(
|
||||
ResolvedSuggestion.AtRoom,
|
||||
ResolvedSuggestion.Member(roomMember),
|
||||
ResolvedSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
|
||||
ResolvedSuggestion.Alias(
|
||||
roomAlias = anAlias,
|
||||
roomId = RoomId("!room:matrix.org"),
|
||||
roomName = "My room",
|
||||
roomAvatarUrl = null,
|
||||
)
|
||||
),
|
||||
onSelectSuggestion = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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.messagecomposer.suggestions
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.core.data.filterUpTo
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||
|
||||
/**
|
||||
* This class is responsible for processing suggestions when `@`, `/` or `#` are type in the composer.
|
||||
*/
|
||||
@Inject
|
||||
class SuggestionsProcessor {
|
||||
/**
|
||||
* Process the suggestion.
|
||||
* @param suggestion The current suggestion input
|
||||
* @param roomMembersState The room members state, it contains the current users in the room
|
||||
* @param roomAliasSuggestions The available room alias suggestions
|
||||
* @param currentUserId The current user id
|
||||
* @param canSendRoomMention Should return true if the current user can send room mentions
|
||||
* @return The list of suggestions to display
|
||||
*/
|
||||
suspend fun process(
|
||||
suggestion: Suggestion?,
|
||||
roomMembersState: RoomMembersState,
|
||||
roomAliasSuggestions: List<RoomAliasSuggestion>,
|
||||
currentUserId: UserId,
|
||||
canSendRoomMention: suspend () -> Boolean,
|
||||
): List<ResolvedSuggestion> {
|
||||
suggestion ?: return emptyList()
|
||||
return when (suggestion.type) {
|
||||
SuggestionType.Mention -> {
|
||||
// Replace suggestions
|
||||
val members = roomMembersState.roomMembers()
|
||||
val matchingMembers = getMemberSuggestions(
|
||||
query = suggestion.text,
|
||||
roomMembers = members,
|
||||
currentUserId = currentUserId,
|
||||
canSendRoomMention = canSendRoomMention()
|
||||
)
|
||||
matchingMembers
|
||||
}
|
||||
SuggestionType.Room -> {
|
||||
roomAliasSuggestions
|
||||
.filter { roomAliasSuggestion ->
|
||||
// Filter by either room alias or room name (if available)
|
||||
roomAliasSuggestion.roomAlias.value.contains(suggestion.text, ignoreCase = true) ||
|
||||
roomAliasSuggestion.roomName?.contains(suggestion.text, ignoreCase = true) == true
|
||||
}
|
||||
.map {
|
||||
ResolvedSuggestion.Alias(
|
||||
roomAlias = it.roomAlias,
|
||||
roomId = it.roomId,
|
||||
roomName = it.roomName,
|
||||
roomAvatarUrl = it.roomAvatarUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
SuggestionType.Command,
|
||||
SuggestionType.Emoji,
|
||||
is SuggestionType.Custom -> {
|
||||
// Clear suggestions
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMemberSuggestions(
|
||||
query: String,
|
||||
roomMembers: List<RoomMember>?,
|
||||
currentUserId: UserId,
|
||||
canSendRoomMention: Boolean,
|
||||
): List<ResolvedSuggestion> {
|
||||
return if (roomMembers.isNullOrEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
fun isJoinedMemberAndNotSelf(member: RoomMember): Boolean {
|
||||
return member.membership == RoomMembershipState.JOIN && currentUserId != member.userId
|
||||
}
|
||||
|
||||
fun memberMatchesQuery(member: RoomMember, query: String): Boolean {
|
||||
return member.userId.value.contains(query, ignoreCase = true) ||
|
||||
member.displayName?.contains(query, ignoreCase = true) == true
|
||||
}
|
||||
|
||||
val matchingMembers = roomMembers
|
||||
// Search only in joined members, up to MAX_BATCH_ITEMS, exclude the current user
|
||||
.filterUpTo(MAX_BATCH_ITEMS) { member ->
|
||||
isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query)
|
||||
}
|
||||
.map(ResolvedSuggestion::Member)
|
||||
|
||||
if ("room".contains(query) && canSendRoomMention) {
|
||||
listOf(ResolvedSuggestion.AtRoom) + matchingMembers
|
||||
} else {
|
||||
matchingMembers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
// We don't want to retrieve thousands of members
|
||||
private const val MAX_BATCH_ITEMS = 100
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.pinned
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.coroutine.mapState
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
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.sync.SyncService
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@SingleIn(RoomScope::class)
|
||||
@ContributesBinding(RoomScope::class)
|
||||
class DefaultPinnedEventsTimelineProvider(
|
||||
private val room: JoinedRoom,
|
||||
private val syncService: SyncService,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : PinnedEventsTimelineProvider {
|
||||
private val _timelineStateFlow: MutableStateFlow<AsyncData<Timeline>> =
|
||||
MutableStateFlow(AsyncData.Uninitialized)
|
||||
|
||||
override fun activeTimelineFlow(): StateFlow<Timeline?> {
|
||||
return _timelineStateFlow
|
||||
.mapState { value ->
|
||||
value.dataOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
val timelineStateFlow = _timelineStateFlow
|
||||
|
||||
fun launchIn(scope: CoroutineScope) {
|
||||
_timelineStateFlow.subscriptionCount
|
||||
.map { count -> count > 0 }
|
||||
.distinctUntilChanged()
|
||||
.onEach { isActive ->
|
||||
if (isActive) {
|
||||
onActive()
|
||||
} else {
|
||||
onInactive()
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
.invokeOnCompletion { timelineStateFlow.value.dataOrNull()?.close() }
|
||||
}
|
||||
|
||||
private suspend fun onActive() = coroutineScope {
|
||||
syncService.syncState.onEach {
|
||||
// do not use syncState here as data can be loaded from cache, it's just to trigger retry if needed
|
||||
loadTimelineIfNeeded()
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
|
||||
private suspend fun onInactive() {
|
||||
resetTimeline()
|
||||
}
|
||||
|
||||
private suspend fun resetTimeline() {
|
||||
invokeOnTimeline {
|
||||
close()
|
||||
}
|
||||
_timelineStateFlow.emit(AsyncData.Uninitialized)
|
||||
}
|
||||
|
||||
suspend fun invokeOnTimeline(action: suspend Timeline.() -> Unit) {
|
||||
when (val asyncTimeline = timelineStateFlow.value) {
|
||||
is AsyncData.Success -> action(asyncTimeline.data)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadTimelineIfNeeded() {
|
||||
when (timelineStateFlow.value) {
|
||||
is AsyncData.Uninitialized, is AsyncData.Failure -> {
|
||||
timelineStateFlow.emit(AsyncData.Loading())
|
||||
withContext(dispatchers.io) {
|
||||
room.createTimeline(CreateTimelineParams.PinnedOnly)
|
||||
}
|
||||
.fold(
|
||||
{ timelineStateFlow.emit(AsyncData.Success(it)) },
|
||||
{ timelineStateFlow.emit(AsyncData.Failure(it)) }
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.pinned.banner
|
||||
|
||||
sealed interface PinnedMessagesBannerEvents {
|
||||
data object MoveToNextPinned : PinnedMessagesBannerEvents
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.pinned.banner
|
||||
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
data class PinnedMessagesBannerItem(
|
||||
val eventId: EventId,
|
||||
val formatted: AnnotatedString,
|
||||
)
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.pinned.banner
|
||||
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Inject
|
||||
class PinnedMessagesBannerItemFactory(
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
private val formatter: PinnedMessagesBannerFormatter,
|
||||
) {
|
||||
suspend fun create(timelineItem: MatrixTimelineItem): PinnedMessagesBannerItem? = withContext(coroutineDispatchers.computation) {
|
||||
when (timelineItem) {
|
||||
is MatrixTimelineItem.Event -> {
|
||||
val eventId = timelineItem.eventId ?: return@withContext null
|
||||
val formatted = formatter.format(timelineItem.event)
|
||||
PinnedMessagesBannerItem(
|
||||
eventId = eventId,
|
||||
formatted = if (formatted is AnnotatedString) {
|
||||
formatted
|
||||
} else {
|
||||
AnnotatedString(formatted.toString())
|
||||
},
|
||||
)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.pinned.banner
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
@Inject
|
||||
class PinnedMessagesBannerPresenter(
|
||||
private val room: BaseRoom,
|
||||
private val itemFactory: PinnedMessagesBannerItemFactory,
|
||||
private val pinnedEventsTimelineProvider: DefaultPinnedEventsTimelineProvider,
|
||||
) : Presenter<PinnedMessagesBannerState> {
|
||||
private val pinnedItems = mutableStateOf<AsyncData<ImmutableList<PinnedMessagesBannerItem>>>(AsyncData.Uninitialized)
|
||||
|
||||
@Composable
|
||||
override fun present(): PinnedMessagesBannerState {
|
||||
val expectedPinnedMessagesCount by remember {
|
||||
room.roomInfoFlow.map { roomInfo -> roomInfo.pinnedEventIds.size }
|
||||
}.collectAsState(initial = 0)
|
||||
|
||||
var currentPinnedMessageIndex by rememberSaveable { mutableIntStateOf(-1) }
|
||||
|
||||
PinnedMessagesBannerItemsEffect(
|
||||
onItemsChange = { newItems ->
|
||||
val pinnedMessageCount = newItems.dataOrNull().orEmpty().size
|
||||
if (currentPinnedMessageIndex >= pinnedMessageCount || currentPinnedMessageIndex < 0) {
|
||||
currentPinnedMessageIndex = pinnedMessageCount - 1
|
||||
}
|
||||
pinnedItems.value = newItems
|
||||
},
|
||||
)
|
||||
|
||||
fun handleEvent(event: PinnedMessagesBannerEvents) {
|
||||
when (event) {
|
||||
is PinnedMessagesBannerEvents.MoveToNextPinned -> {
|
||||
val loadedCount = pinnedItems.value.dataOrNull().orEmpty().size
|
||||
currentPinnedMessageIndex = (currentPinnedMessageIndex - 1).mod(loadedCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pinnedMessagesBannerState(
|
||||
expectedPinnedMessagesCount = expectedPinnedMessagesCount,
|
||||
pinnedItems = pinnedItems.value,
|
||||
currentPinnedMessageIndex = currentPinnedMessageIndex,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun pinnedMessagesBannerState(
|
||||
expectedPinnedMessagesCount: Int,
|
||||
pinnedItems: AsyncData<ImmutableList<PinnedMessagesBannerItem>>,
|
||||
currentPinnedMessageIndex: Int,
|
||||
eventSink: (PinnedMessagesBannerEvents) -> Unit
|
||||
): PinnedMessagesBannerState {
|
||||
return when (pinnedItems) {
|
||||
is AsyncData.Failure, is AsyncData.Uninitialized -> PinnedMessagesBannerState.Hidden
|
||||
is AsyncData.Loading -> {
|
||||
if (expectedPinnedMessagesCount == 0) {
|
||||
PinnedMessagesBannerState.Hidden
|
||||
} else {
|
||||
PinnedMessagesBannerState.Loading(expectedPinnedMessagesCount = expectedPinnedMessagesCount)
|
||||
}
|
||||
}
|
||||
is AsyncData.Success -> {
|
||||
val currentPinnedMessage = pinnedItems.data.getOrNull(currentPinnedMessageIndex)
|
||||
if (currentPinnedMessage == null) {
|
||||
PinnedMessagesBannerState.Hidden
|
||||
} else {
|
||||
PinnedMessagesBannerState.Loaded(
|
||||
loadedPinnedMessagesCount = pinnedItems.data.size,
|
||||
currentPinnedMessageIndex = currentPinnedMessageIndex,
|
||||
currentPinnedMessage = currentPinnedMessage,
|
||||
eventSink = eventSink
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Composable
|
||||
private fun PinnedMessagesBannerItemsEffect(
|
||||
onItemsChange: (AsyncData<ImmutableList<PinnedMessagesBannerItem>>) -> Unit,
|
||||
) {
|
||||
val updatedOnItemsChange by rememberUpdatedState(onItemsChange)
|
||||
LaunchedEffect(Unit) {
|
||||
pinnedEventsTimelineProvider.timelineStateFlow
|
||||
.flatMapLatest { asyncTimeline ->
|
||||
when (asyncTimeline) {
|
||||
AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized)
|
||||
is AsyncData.Failure -> flowOf(AsyncData.Failure(asyncTimeline.error))
|
||||
is AsyncData.Loading -> flowOf(AsyncData.Loading())
|
||||
is AsyncData.Success -> {
|
||||
asyncTimeline.data.timelineItems
|
||||
.map { timelineItems ->
|
||||
val pinnedItems = timelineItems.mapNotNull { timelineItem ->
|
||||
itemFactory.create(timelineItem)
|
||||
}.toImmutableList()
|
||||
|
||||
AsyncData.Success(pinnedItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEach { newItems ->
|
||||
updatedOnItemsChange(newItems)
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.pinned.banner
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.libraries.designsystem.text.toAnnotatedString
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Immutable
|
||||
sealed interface PinnedMessagesBannerState {
|
||||
data object Hidden : PinnedMessagesBannerState
|
||||
@Immutable
|
||||
sealed interface Visible : PinnedMessagesBannerState {
|
||||
fun pinnedMessagesCount() = when (this) {
|
||||
is Loading -> expectedPinnedMessagesCount
|
||||
is Loaded -> loadedPinnedMessagesCount
|
||||
}
|
||||
|
||||
fun currentPinnedMessageIndex() = when (this) {
|
||||
is Loading -> expectedPinnedMessagesCount - 1
|
||||
is Loaded -> currentPinnedMessageIndex
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun formattedMessage() = when (this) {
|
||||
is Loading -> stringResource(id = CommonStrings.screen_room_pinned_banner_loading_description).toAnnotatedString()
|
||||
is Loaded -> currentPinnedMessage.formatted
|
||||
}
|
||||
}
|
||||
|
||||
data class Loading(val expectedPinnedMessagesCount: Int) : Visible
|
||||
data class Loaded(
|
||||
val currentPinnedMessage: PinnedMessagesBannerItem,
|
||||
val currentPinnedMessageIndex: Int,
|
||||
val loadedPinnedMessagesCount: Int,
|
||||
val eventSink: (PinnedMessagesBannerEvents) -> Unit
|
||||
) : Visible
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.pinned.banner
|
||||
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import kotlin.random.Random
|
||||
|
||||
internal class PinnedMessagesBannerStateProvider : PreviewParameterProvider<PinnedMessagesBannerState> {
|
||||
override val values: Sequence<PinnedMessagesBannerState>
|
||||
get() = sequenceOf(
|
||||
aHiddenPinnedMessagesBannerState(),
|
||||
aLoadingPinnedMessagesBannerState(knownPinnedMessagesCount = 1),
|
||||
aLoadingPinnedMessagesBannerState(knownPinnedMessagesCount = 5),
|
||||
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 1, currentPinnedMessageIndex = 0),
|
||||
aLoadedPinnedMessagesBannerState(
|
||||
knownPinnedMessagesCount = 2,
|
||||
currentPinnedMessageIndex = 0,
|
||||
message = "This is a pinned long message to check the wrapping behavior",
|
||||
),
|
||||
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 3, currentPinnedMessageIndex = 0),
|
||||
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 5, currentPinnedMessageIndex = 0),
|
||||
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 5, currentPinnedMessageIndex = 1),
|
||||
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 5, currentPinnedMessageIndex = 2),
|
||||
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 5, currentPinnedMessageIndex = 3),
|
||||
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 5, currentPinnedMessageIndex = 4),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aHiddenPinnedMessagesBannerState() = PinnedMessagesBannerState.Hidden
|
||||
|
||||
internal fun aLoadingPinnedMessagesBannerState(
|
||||
knownPinnedMessagesCount: Int = 4
|
||||
) = PinnedMessagesBannerState.Loading(
|
||||
expectedPinnedMessagesCount = knownPinnedMessagesCount
|
||||
)
|
||||
|
||||
internal fun aLoadedPinnedMessagesBannerState(
|
||||
currentPinnedMessageIndex: Int = 0,
|
||||
knownPinnedMessagesCount: Int = 1,
|
||||
message: String = "This is a pinned message",
|
||||
currentPinnedMessage: PinnedMessagesBannerItem = PinnedMessagesBannerItem(
|
||||
eventId = EventId("\$" + Random.nextInt().toString()),
|
||||
formatted = AnnotatedString(message)
|
||||
),
|
||||
eventSink: (PinnedMessagesBannerEvents) -> Unit = {}
|
||||
) = PinnedMessagesBannerState.Loaded(
|
||||
currentPinnedMessage = currentPinnedMessage,
|
||||
currentPinnedMessageIndex = currentPinnedMessageIndex,
|
||||
loadedPinnedMessagesCount = knownPinnedMessagesCount,
|
||||
eventSink = eventSink
|
||||
)
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.pinned.banner
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import im.vector.app.features.analytics.plan.Interaction
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
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.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.pinnedMessageBannerBorder
|
||||
import io.element.android.libraries.designsystem.theme.pinnedMessageBannerIndicator
|
||||
import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun PinnedMessagesBannerView(
|
||||
state: PinnedMessagesBannerState,
|
||||
onClick: (EventId) -> Unit,
|
||||
onViewAllClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (state) {
|
||||
PinnedMessagesBannerState.Hidden -> Unit
|
||||
is PinnedMessagesBannerState.Visible -> {
|
||||
PinnedMessagesBannerRow(
|
||||
state = state,
|
||||
onClick = onClick,
|
||||
onViewAllClick = onViewAllClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinnedMessagesBannerRow(
|
||||
state: PinnedMessagesBannerState.Visible,
|
||||
onClick: (EventId) -> Unit,
|
||||
onViewAllClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val analyticsService = LocalAnalyticsService.current
|
||||
val borderColor = ElementTheme.colors.pinnedMessageBannerBorder
|
||||
Row(
|
||||
modifier = modifier
|
||||
.background(color = ElementTheme.colors.bgCanvasDefault)
|
||||
.fillMaxWidth()
|
||||
.drawBorder(borderColor)
|
||||
.heightIn(min = 64.dp)
|
||||
.clickable {
|
||||
if (state is PinnedMessagesBannerState.Loaded) {
|
||||
analyticsService.captureInteraction(Interaction.Name.PinnedMessageBannerClick)
|
||||
onClick(state.currentPinnedMessage.eventId)
|
||||
state.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned)
|
||||
}
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(26.dp))
|
||||
PinIndicators(
|
||||
pinIndex = state.currentPinnedMessageIndex(),
|
||||
pinsCount = state.pinnedMessagesCount(),
|
||||
)
|
||||
Icon(
|
||||
imageVector = CompoundIcons.PinSolid(),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconSecondary,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 10.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
PinnedMessageItem(
|
||||
index = state.currentPinnedMessageIndex(),
|
||||
totalCount = state.pinnedMessagesCount(),
|
||||
message = state.formattedMessage(),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ViewAllButton(
|
||||
state = state,
|
||||
onViewAllClick = {
|
||||
onViewAllClick()
|
||||
analyticsService.captureInteraction(Interaction.Name.PinnedMessageBannerViewAllButton)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ViewAllButton(
|
||||
state: PinnedMessagesBannerState,
|
||||
onViewAllClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val text = if (state is PinnedMessagesBannerState.Loaded) {
|
||||
stringResource(id = CommonStrings.screen_room_pinned_banner_view_all_button_title)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
TextButton(
|
||||
text = text,
|
||||
showProgress = state is PinnedMessagesBannerState.Loading,
|
||||
onClick = onViewAllClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Modifier.drawBorder(borderColor: Color): Modifier {
|
||||
return this
|
||||
.drawBehind {
|
||||
val strokeWidth = 0.5.dp.toPx()
|
||||
val y = size.height - strokeWidth / 2
|
||||
drawLine(
|
||||
borderColor,
|
||||
Offset(0f, y),
|
||||
Offset(size.width, y),
|
||||
strokeWidth
|
||||
)
|
||||
drawLine(
|
||||
borderColor,
|
||||
Offset(0f, 0f),
|
||||
Offset(size.width, 0f),
|
||||
strokeWidth
|
||||
)
|
||||
}
|
||||
.shadow(elevation = 5.dp, spotColor = Color.Transparent)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinIndicators(
|
||||
pinIndex: Int,
|
||||
pinsCount: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val indicatorHeight = remember(pinsCount) {
|
||||
when (pinsCount) {
|
||||
0 -> 0
|
||||
1 -> 32
|
||||
2 -> 18
|
||||
else -> 11
|
||||
}
|
||||
}
|
||||
val activeIndex = remember(pinIndex) {
|
||||
pinIndex % 3
|
||||
}
|
||||
val shownIndicators = remember(pinsCount, pinIndex) {
|
||||
if (pinsCount <= 3) {
|
||||
pinsCount
|
||||
} else {
|
||||
val isLastPage = pinIndex >= pinsCount - pinsCount % 3
|
||||
if (isLastPage) {
|
||||
pinsCount % 3
|
||||
} else {
|
||||
3
|
||||
}
|
||||
}
|
||||
}
|
||||
val indicatorsCount = pinsCount.coerceAtMost(3)
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = spacedBy(2.dp)
|
||||
) {
|
||||
for (index in 0 until indicatorsCount) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(2.dp)
|
||||
.height(indicatorHeight.dp)
|
||||
.background(
|
||||
color = if (index == activeIndex) {
|
||||
ElementTheme.colors.iconAccentPrimary
|
||||
} else if (index < shownIndicators) {
|
||||
ElementTheme.colors.pinnedMessageBannerIndicator
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinnedMessageItem(
|
||||
index: Int,
|
||||
totalCount: Int,
|
||||
message: AnnotatedString?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val countMessage = stringResource(id = CommonStrings.screen_room_pinned_banner_indicator, index + 1, totalCount)
|
||||
val fullCountMessage = stringResource(id = CommonStrings.screen_room_pinned_banner_indicator_description, countMessage)
|
||||
Column(modifier = modifier) {
|
||||
AnimatedVisibility(totalCount > 1) {
|
||||
Text(
|
||||
text = annotatedTextWithBold(
|
||||
text = fullCountMessage,
|
||||
boldText = countMessage,
|
||||
),
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
color = ElementTheme.colors.textActionAccent,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (message != null) {
|
||||
Text(
|
||||
text = message,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
internal interface PinnedMessagesBannerViewScrollBehavior {
|
||||
val isVisible: Boolean
|
||||
val nestedScrollConnection: NestedScrollConnection
|
||||
}
|
||||
|
||||
internal object PinnedMessagesBannerViewDefaults {
|
||||
@Composable
|
||||
fun rememberScrollBehavior(pinnedMessagesCount: Int): PinnedMessagesBannerViewScrollBehavior = remember(pinnedMessagesCount) {
|
||||
ExitOnScrollBehavior()
|
||||
}
|
||||
}
|
||||
|
||||
private class ExitOnScrollBehavior : PinnedMessagesBannerViewScrollBehavior {
|
||||
override var isVisible by mutableStateOf(true)
|
||||
override val nestedScrollConnection: NestedScrollConnection = object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
if (available.y < -1) {
|
||||
isVisible = true
|
||||
}
|
||||
if (available.y > 1) {
|
||||
isVisible = false
|
||||
}
|
||||
return Offset.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PinnedMessagesBannerViewPreview(@PreviewParameter(PinnedMessagesBannerStateProvider::class) state: PinnedMessagesBannerState) = ElementPreview {
|
||||
PinnedMessagesBannerView(
|
||||
state = state,
|
||||
onClick = {},
|
||||
onViewAllClick = {},
|
||||
)
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.pinned.list
|
||||
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
|
||||
sealed interface PinnedMessagesListEvents {
|
||||
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : PinnedMessagesListEvents
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.pinned.list
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
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)
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.pinned.list
|
||||
|
||||
import android.content.Context
|
||||
import android.view.HapticFeedbackConstants
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.androidutils.system.copyToClipboard
|
||||
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
|
||||
import io.element.android.libraries.architecture.callback
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
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.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.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
@AssistedInject
|
||||
class PinnedMessagesListNode(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: PinnedMessagesListPresenter.Factory,
|
||||
actionListPresenterFactory: ActionListPresenter.Factory,
|
||||
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
|
||||
interface Callback : Plugin {
|
||||
fun handleEventClick(event: TimelineItem.Event)
|
||||
fun navigateToRoomMemberDetails(userId: UserId)
|
||||
fun viewInTimeline(eventId: EventId)
|
||||
fun handlePermalinkClick(data: PermalinkData.RoomLink)
|
||||
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||
fun handleForwardEventClick(eventId: EventId)
|
||||
}
|
||||
|
||||
private val callback: Callback = callback()
|
||||
private val presenter = presenterFactory.create(
|
||||
navigator = this,
|
||||
actionListPresenter = actionListPresenterFactory.create(
|
||||
postProcessor = PinnedMessagesListTimelineActionPostProcessor(),
|
||||
timelineMode = Timeline.Mode.PinnedEvents,
|
||||
)
|
||||
)
|
||||
|
||||
private fun onLinkClick(context: Context, url: String) {
|
||||
when (val permalink = permalinkParser.parse(url)) {
|
||||
is PermalinkData.UserLink -> {
|
||||
// Open the room member profile, it will fallback to
|
||||
// the user profile if the user is not in the room
|
||||
callback.navigateToRoomMemberDetails(permalink.userId)
|
||||
}
|
||||
is PermalinkData.RoomLink -> {
|
||||
callback.handlePermalinkClick(permalink)
|
||||
}
|
||||
is PermalinkData.FallbackLink,
|
||||
is PermalinkData.RoomEmailInviteLink -> {
|
||||
context.openUrlInExternalApp(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun viewInTimeline(eventId: EventId) {
|
||||
callback.viewInTimeline(eventId)
|
||||
}
|
||||
|
||||
override fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||
callback.navigateToEventDebugInfo(eventId, debugInfo)
|
||||
}
|
||||
|
||||
override fun forwardEvent(eventId: EventId) {
|
||||
callback.handleForwardEventClick(eventId)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
CompositionLocalProvider(
|
||||
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val view = LocalView.current
|
||||
val state = presenter.present()
|
||||
PinnedMessagesListView(
|
||||
state = state,
|
||||
onBackClick = ::navigateUp,
|
||||
onEventClick = callback::handleEventClick,
|
||||
onUserDataClick = { callback.navigateToRoomMemberDetails(it.userId) },
|
||||
onLinkClick = { link -> onLinkClick(context, link.url) },
|
||||
onLinkLongClick = {
|
||||
view.performHapticFeedback(
|
||||
HapticFeedbackConstants.LONG_PRESS
|
||||
)
|
||||
context.copyToClipboard(
|
||||
it.url,
|
||||
context.getString(CommonStrings.common_copied_to_clipboard)
|
||||
)
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.pinned.list
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.Interaction
|
||||
import im.vector.app.features.analytics.plan.PinUnpinAction
|
||||
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
||||
import io.element.android.features.messages.impl.UserEventPermissions
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.link.LinkState
|
||||
import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||
import io.element.android.features.roomcall.api.aStandByCallState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.matrix.ui.room.isDmAsState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
@AssistedInject
|
||||
class PinnedMessagesListPresenter(
|
||||
@Assisted private val navigator: PinnedMessagesListNavigator,
|
||||
private val room: JoinedRoom,
|
||||
timelineItemsFactoryCreator: TimelineItemsFactory.Creator,
|
||||
private val timelineProvider: DefaultPinnedEventsTimelineProvider,
|
||||
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
|
||||
private val linkPresenter: Presenter<LinkState>,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
@Assisted private val actionListPresenter: Presenter<ActionListState>,
|
||||
@SessionCoroutineScope
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val htmlConverterProvider: HtmlConverterProvider,
|
||||
) : Presenter<PinnedMessagesListState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
navigator: PinnedMessagesListNavigator,
|
||||
actionListPresenter: Presenter<ActionListState>,
|
||||
): PinnedMessagesListPresenter
|
||||
}
|
||||
|
||||
private val timelineItemsFactory: TimelineItemsFactory = timelineItemsFactoryCreator.create(
|
||||
config = TimelineItemsFactoryConfig(
|
||||
computeReadReceipts = false,
|
||||
computeReactions = false,
|
||||
)
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun present(): PinnedMessagesListState {
|
||||
htmlConverterProvider.Update()
|
||||
val isDm by room.isDmAsState()
|
||||
|
||||
val timelineRoomInfo = remember(isDm) {
|
||||
TimelineRoomInfo(
|
||||
isDm = isDm,
|
||||
name = room.info().name,
|
||||
// We don't need to compute those values
|
||||
userHasPermissionToSendMessage = false,
|
||||
userHasPermissionToSendReaction = false,
|
||||
// We do not care about the call state here.
|
||||
roomCallState = aStandByCallState(),
|
||||
// don't compute this value or the pin icon will be shown
|
||||
pinnedEventIds = persistentListOf(),
|
||||
typingNotificationState = TypingNotificationState(
|
||||
renderTypingNotifications = false,
|
||||
typingMembers = persistentListOf(),
|
||||
reserveSpace = false,
|
||||
),
|
||||
predecessorRoom = room.predecessorRoom(),
|
||||
)
|
||||
}
|
||||
val timelineProtectionState = timelineProtectionPresenter.present()
|
||||
val linkState = linkPresenter.present()
|
||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||
val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
|
||||
|
||||
val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false)
|
||||
|
||||
var pinnedMessageItems by remember {
|
||||
mutableStateOf<AsyncData<ImmutableList<TimelineItem>>>(AsyncData.Uninitialized)
|
||||
}
|
||||
PinnedMessagesListEffect(
|
||||
onItemsChange = { newItems ->
|
||||
pinnedMessageItems = newItems
|
||||
}
|
||||
)
|
||||
|
||||
fun handleEvent(event: PinnedMessagesListEvents) {
|
||||
when (event) {
|
||||
is PinnedMessagesListEvents.HandleAction -> sessionCoroutineScope.handleTimelineAction(event.action, event.event)
|
||||
}
|
||||
}
|
||||
|
||||
return pinnedMessagesListState(
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
linkState = linkState,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
userEventPermissions = userEventPermissions,
|
||||
timelineItems = pinnedMessageItems,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.handleTimelineAction(
|
||||
action: TimelineItemAction,
|
||||
targetEvent: TimelineItem.Event,
|
||||
) = launch {
|
||||
when (action) {
|
||||
TimelineItemAction.ViewSource -> {
|
||||
navigator.navigateToEventDebugInfo(targetEvent.eventId, targetEvent.debugInfo)
|
||||
}
|
||||
TimelineItemAction.Forward -> {
|
||||
targetEvent.eventId?.let { eventId ->
|
||||
navigator.forwardEvent(eventId)
|
||||
}
|
||||
}
|
||||
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
|
||||
TimelineItemAction.ViewInTimeline -> {
|
||||
targetEvent.eventId?.let { eventId ->
|
||||
analyticsService.captureInteraction(Interaction.Name.PinnedMessageListViewTimeline)
|
||||
navigator.viewInTimeline(eventId)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleUnpinAction(targetEvent: TimelineItem.Event) {
|
||||
if (targetEvent.eventId == null) return
|
||||
analyticsService.capture(
|
||||
PinUnpinAction(
|
||||
from = PinUnpinAction.From.MessagePinningList,
|
||||
kind = PinUnpinAction.Kind.Unpin,
|
||||
)
|
||||
)
|
||||
timelineProvider.invokeOnTimeline {
|
||||
unpinEvent(targetEvent.eventId)
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to unpin event ${targetEvent.eventId}")
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun userEventPermissions(updateKey: Long): State<UserEventPermissions> {
|
||||
return produceState(UserEventPermissions.DEFAULT, key1 = updateKey) {
|
||||
value = UserEventPermissions(
|
||||
canSendMessage = false,
|
||||
canSendReaction = false,
|
||||
canRedactOwn = room.canRedactOwn().getOrElse { false },
|
||||
canRedactOther = room.canRedactOther().getOrElse { false },
|
||||
canPinUnpin = room.canPinUnpin().getOrElse { false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinnedMessagesListEffect(onItemsChange: (AsyncData<ImmutableList<TimelineItem>>) -> Unit) {
|
||||
val updatedOnItemsChange by rememberUpdatedState(onItemsChange)
|
||||
|
||||
val timelineState by timelineProvider.timelineStateFlow.collectAsState()
|
||||
|
||||
LaunchedEffect(timelineState) {
|
||||
when (val asyncTimeline = timelineState) {
|
||||
AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized)
|
||||
is AsyncData.Failure -> flowOf(AsyncData.Failure(asyncTimeline.error))
|
||||
is AsyncData.Loading -> flowOf(AsyncData.Loading())
|
||||
is AsyncData.Success -> {
|
||||
val timelineItemsFlow = asyncTimeline.data.timelineItems
|
||||
combine(timelineItemsFlow, room.membersStateFlow) { items, membersState ->
|
||||
timelineItemsFactory.replaceWith(
|
||||
timelineItems = items,
|
||||
roomMembers = membersState.roomMembers().orEmpty()
|
||||
)
|
||||
}.launchIn(this)
|
||||
|
||||
timelineItemsFactory.timelineItems.map { timelineItems ->
|
||||
AsyncData.Success(timelineItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEach { items ->
|
||||
updatedOnItemsChange(items)
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun pinnedMessagesListState(
|
||||
timelineRoomInfo: TimelineRoomInfo,
|
||||
timelineProtectionState: TimelineProtectionState,
|
||||
displayThreadSummaries: Boolean,
|
||||
linkState: LinkState,
|
||||
userEventPermissions: UserEventPermissions,
|
||||
timelineItems: AsyncData<ImmutableList<TimelineItem>>,
|
||||
eventSink: (PinnedMessagesListEvents) -> Unit
|
||||
): PinnedMessagesListState {
|
||||
return when (timelineItems) {
|
||||
AsyncData.Uninitialized, is AsyncData.Loading -> PinnedMessagesListState.Loading
|
||||
is AsyncData.Failure -> PinnedMessagesListState.Failed
|
||||
is AsyncData.Success -> {
|
||||
if (timelineItems.data.isEmpty()) {
|
||||
PinnedMessagesListState.Empty
|
||||
} else {
|
||||
val actionListState = actionListPresenter.present()
|
||||
PinnedMessagesListState.Filled(
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
linkState = linkState,
|
||||
userEventPermissions = userEventPermissions,
|
||||
timelineItems = timelineItems.data,
|
||||
actionListState = actionListState,
|
||||
eventSink = eventSink
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.pinned.list
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.features.messages.impl.UserEventPermissions
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||
import io.element.android.features.messages.impl.link.LinkState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.libraries.ui.strings.CommonPlurals
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
sealed interface PinnedMessagesListState {
|
||||
data object Failed : PinnedMessagesListState
|
||||
data object Loading : PinnedMessagesListState
|
||||
data object Empty : PinnedMessagesListState
|
||||
data class Filled(
|
||||
val timelineRoomInfo: TimelineRoomInfo,
|
||||
val timelineProtectionState: TimelineProtectionState,
|
||||
val userEventPermissions: UserEventPermissions,
|
||||
val timelineItems: ImmutableList<TimelineItem>,
|
||||
val actionListState: ActionListState,
|
||||
val linkState: LinkState,
|
||||
val displayThreadSummaries: Boolean,
|
||||
val eventSink: (PinnedMessagesListEvents) -> Unit,
|
||||
) : PinnedMessagesListState {
|
||||
val loadedPinnedMessagesCount = timelineItems.count { timelineItem -> timelineItem is TimelineItem.Event }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun title(): String {
|
||||
return when (this) {
|
||||
is Filled -> {
|
||||
pluralStringResource(id = CommonPlurals.screen_pinned_timeline_screen_title, loadedPinnedMessagesCount, loadedPinnedMessagesCount)
|
||||
}
|
||||
else -> stringResource(id = CommonStrings.screen_pinned_timeline_screen_title_empty)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user