Introduce Attachments and LocalMedia (WIP)
This commit is contained in:
parent
72dea2f817
commit
266d48b48b
25 changed files with 634 additions and 119 deletions
|
|
@ -50,6 +50,9 @@ dependencies {
|
||||||
implementation(libs.accompanist.flowlayout)
|
implementation(libs.accompanist.flowlayout)
|
||||||
implementation(libs.androidx.recyclerview)
|
implementation(libs.androidx.recyclerview)
|
||||||
implementation(libs.jsoup)
|
implementation(libs.jsoup)
|
||||||
|
implementation(libs.androidx.media3.exoplayer)
|
||||||
|
implementation(libs.androidx.media3.ui)
|
||||||
|
implementation(libs.accompanist.systemui)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
testImplementation(libs.coroutines.test)
|
testImplementation(libs.coroutines.test)
|
||||||
|
|
|
||||||
|
|
@ -26,18 +26,23 @@ import com.bumble.appyx.core.plugin.Plugin
|
||||||
import com.bumble.appyx.core.plugin.plugins
|
import com.bumble.appyx.core.plugin.plugins
|
||||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
|
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.features.messages.api.MessagesEntryPoint
|
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.media.viewer.MediaViewerNode
|
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
|
||||||
import io.element.android.features.messages.impl.media.viewer.model.MediaContentUiModel
|
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.matrix.api.media.MatrixMediaSource
|
||||||
import kotlinx.android.parcel.Parcelize
|
import kotlinx.android.parcel.Parcelize
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@ContributesNode(RoomScope::class)
|
@ContributesNode(RoomScope::class)
|
||||||
class MessagesFlowNode @AssistedInject constructor(
|
class MessagesFlowNode @AssistedInject constructor(
|
||||||
|
|
@ -57,7 +62,10 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
object Messages : NavTarget
|
object Messages : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class MediaViewer(val mediaContent: MediaContentUiModel) : NavTarget
|
data class MediaViewer(val title: String, val mediaSource: MatrixMediaSource) : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class AttachmentPreview(val attachment: Attachment) : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
|
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
|
||||||
|
|
@ -73,25 +81,35 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
override fun onEventClicked(event: TimelineItem.Event) {
|
override fun onEventClicked(event: TimelineItem.Event) {
|
||||||
processEventClicked(event)
|
processEventClicked(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
|
||||||
|
backstack.push(NavTarget.AttachmentPreview(attachments.first()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
createNode<MessagesNode>(buildContext, listOf(callback))
|
createNode<MessagesNode>(buildContext, listOf(callback))
|
||||||
}
|
}
|
||||||
is NavTarget.MediaViewer -> {
|
is NavTarget.MediaViewer -> {
|
||||||
val inputs = MediaViewerNode.Inputs(navTarget.mediaContent)
|
val inputs = MediaViewerNode.Inputs(navTarget.title, navTarget.mediaSource)
|
||||||
createNode<MediaViewerNode>(buildContext, listOf(inputs))
|
createNode<MediaViewerNode>(buildContext, listOf(inputs))
|
||||||
}
|
}
|
||||||
|
is NavTarget.AttachmentPreview -> {
|
||||||
|
val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment)
|
||||||
|
createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun processEventClicked(event: TimelineItem.Event) {
|
private fun processEventClicked(event: TimelineItem.Event) {
|
||||||
when (event.content) {
|
when (event.content) {
|
||||||
is TimelineItemImageContent -> {
|
is TimelineItemImageContent -> {
|
||||||
val mediaContent = MediaContentUiModel.Image(
|
val mediaSource = event.content.mediaSource
|
||||||
body = event.content.body,
|
val navTarget = NavTarget.MediaViewer(event.content.body, mediaSource)
|
||||||
url = event.content.mediaRequestData.url,
|
backstack.push(navTarget)
|
||||||
blurhash = event.content.blurhash
|
}
|
||||||
)
|
is TimelineItemVideoContent -> {
|
||||||
backstack.push(NavTarget.MediaViewer(mediaContent))
|
val mediaSource = event.content.videoSource
|
||||||
|
val navTarget = NavTarget.MediaViewer(event.content.body, mediaSource)
|
||||||
|
backstack.push(navTarget)
|
||||||
}
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
|
|
@ -102,6 +120,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
Children(
|
Children(
|
||||||
navModel = backstack,
|
navModel = backstack,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
transitionHandler = rememberBackstackFader()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,10 @@ import com.bumble.appyx.core.plugin.plugins
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@ContributesNode(RoomScope::class)
|
@ContributesNode(RoomScope::class)
|
||||||
class MessagesNode @AssistedInject constructor(
|
class MessagesNode @AssistedInject constructor(
|
||||||
|
|
@ -40,6 +42,7 @@ class MessagesNode @AssistedInject constructor(
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun onRoomDetailsClicked()
|
fun onRoomDetailsClicked()
|
||||||
fun onEventClicked(event: TimelineItem.Event)
|
fun onEventClicked(event: TimelineItem.Event)
|
||||||
|
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onRoomDetailsClicked() {
|
private fun onRoomDetailsClicked() {
|
||||||
|
|
@ -50,6 +53,10 @@ class MessagesNode @AssistedInject constructor(
|
||||||
callback?.onEventClicked(event)
|
callback?.onEventClicked(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
|
||||||
|
callback?.onPreviewAttachments(attachments)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
|
|
@ -58,6 +65,7 @@ class MessagesNode @AssistedInject constructor(
|
||||||
onBackPressed = this::navigateUp,
|
onBackPressed = this::navigateUp,
|
||||||
onRoomDetailsClicked = this::onRoomDetailsClicked,
|
onRoomDetailsClicked = this::onRoomDetailsClicked,
|
||||||
onEventClicked = this::onEventClicked,
|
onEventClicked = this::onEventClicked,
|
||||||
|
onPreviewAttachments = this::onPreviewAttachments,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,9 @@ import androidx.compose.ui.unit.sp
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
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.ActionListView
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker
|
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker
|
||||||
|
import io.element.android.features.messages.impl.textcomposer.AttachmentsState
|
||||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerEvents
|
import io.element.android.features.messages.impl.textcomposer.MessageComposerEvents
|
||||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerView
|
import io.element.android.features.messages.impl.textcomposer.MessageComposerView
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineView
|
import io.element.android.features.messages.impl.timeline.TimelineView
|
||||||
|
|
@ -80,6 +82,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
|
|
@ -89,6 +92,7 @@ fun MessagesView(
|
||||||
onBackPressed: () -> Unit,
|
onBackPressed: () -> Unit,
|
||||||
onRoomDetailsClicked: () -> Unit,
|
onRoomDetailsClicked: () -> Unit,
|
||||||
onEventClicked: (event: TimelineItem.Event) -> Unit,
|
onEventClicked: (event: TimelineItem.Event) -> Unit,
|
||||||
|
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
||||||
|
|
@ -105,6 +109,13 @@ fun MessagesView(
|
||||||
val bottomSheetState = rememberModalBottomSheetState(initialValue = initialBottomSheetState)
|
val bottomSheetState = rememberModalBottomSheetState(initialValue = initialBottomSheetState)
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val attachmentsState = state.composerState.attachmentsState
|
||||||
|
if (attachmentsState is AttachmentsState.Previewing) {
|
||||||
|
LaunchedEffect(attachmentsState) {
|
||||||
|
onPreviewAttachments(attachmentsState.attachments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BackHandler(enabled = bottomSheetState.isVisible) {
|
BackHandler(enabled = bottomSheetState.isVisible) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
bottomSheetState.hide()
|
bottomSheetState.hide()
|
||||||
|
|
@ -327,5 +338,5 @@ internal fun MessagesViewDarkPreview(@PreviewParameter(MessagesStateProvider::cl
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview(state: MessagesState) {
|
private fun ContentToPreview(state: MessagesState) {
|
||||||
MessagesView(state, {}, {}, {})
|
MessagesView(state, {}, {}, {}, {})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.attachments
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import io.element.android.features.messages.impl.media.local.LocalMedia
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
sealed interface Attachment : Parcelable {
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class Media(val localMedia: LocalMedia) : Attachment
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.attachments.preview
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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 dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
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
|
||||||
|
|
||||||
|
@ContributesNode(RoomScope::class)
|
||||||
|
class AttachmentsPreviewNode @AssistedInject constructor(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
presenterFactory: AttachmentsPreviewPresenter.Factory,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
|
data class Inputs(val attachment: Attachment) : NodeInputs
|
||||||
|
|
||||||
|
private val inputs: Inputs = inputs()
|
||||||
|
|
||||||
|
private val presenter = presenterFactory.create(inputs.attachment)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
val state = presenter.present()
|
||||||
|
AttachmentsPreviewView(
|
||||||
|
state = state,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.attachments.preview
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
|
||||||
|
class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
|
@Assisted private val attachment: Attachment,
|
||||||
|
) : Presenter<AttachmentsPreviewState> {
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(attachment: Attachment): AttachmentsPreviewPresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): AttachmentsPreviewState {
|
||||||
|
|
||||||
|
return AttachmentsPreviewState(
|
||||||
|
attachment = attachment,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.attachments.preview
|
||||||
|
|
||||||
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
|
|
||||||
|
data class AttachmentsPreviewState(
|
||||||
|
val attachment: Attachment,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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.media.local.LocalMedia
|
||||||
|
|
||||||
|
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
|
||||||
|
override val values: Sequence<AttachmentsPreviewState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aAttachmentsPreviewState(),
|
||||||
|
// Add other states here
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aAttachmentsPreviewState() = AttachmentsPreviewState(
|
||||||
|
attachment = Attachment.Media(
|
||||||
|
localMedia = LocalMedia("".toUri(), mimeType = null)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.attachments.preview
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
|
import io.element.android.features.messages.impl.media.local.LocalMediaView
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AttachmentsPreviewView(
|
||||||
|
state: AttachmentsPreviewState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Scaffold(modifier) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier,
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
when (state.attachment) {
|
||||||
|
is Attachment.Media -> LocalMediaView(localMedia = state.attachment.localMedia)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun AttachmentsPreviewViewLightPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) =
|
||||||
|
ElementPreviewLight { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun AttachmentsPreviewViewDarkPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) =
|
||||||
|
ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview(state: AttachmentsPreviewState) {
|
||||||
|
AttachmentsPreviewView(
|
||||||
|
state = state,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.media.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class AndroidLocalMediaFactory @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) : LocalMediaFactory {
|
||||||
|
|
||||||
|
override fun createFromUri(uri: Uri?, mimeType: String?): LocalMedia? {
|
||||||
|
if (uri == null) return null
|
||||||
|
val resolvedMimeType = mimeType ?: context.contentResolver.getType(uri)
|
||||||
|
return LocalMedia(uri, resolvedMimeType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.media.local
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
class FakeLocalMediaFactory() : LocalMediaFactory {
|
||||||
|
|
||||||
|
var mimeType: String? = null
|
||||||
|
|
||||||
|
override fun createFromUri(uri: Uri?, mimeType: String?): LocalMedia? {
|
||||||
|
if (uri == null) return null
|
||||||
|
return LocalMedia(uri, mimeType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,27 +14,14 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.media.viewer.model
|
package io.element.android.features.messages.impl.media.local
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
|
||||||
import kotlinx.android.parcel.Parcelize
|
import kotlinx.android.parcel.Parcelize
|
||||||
|
|
||||||
sealed interface MediaContentUiModel : Parcelable {
|
@Parcelize
|
||||||
|
data class LocalMedia(
|
||||||
@Parcelize
|
val uri: Uri,
|
||||||
data class Image(
|
val mimeType: String?,
|
||||||
val body: String,
|
) : Parcelable
|
||||||
val url: String,
|
|
||||||
val blurhash: String?,
|
|
||||||
) : MediaContentUiModel {
|
|
||||||
val mediaRequestData = MediaRequestData(
|
|
||||||
url = url, kind = MediaRequestData.Kind.Content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data class Video(
|
|
||||||
val body: String,
|
|
||||||
) : MediaContentUiModel
|
|
||||||
}
|
|
||||||
|
|
@ -14,9 +14,10 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.media.viewer
|
package io.element.android.features.messages.impl.media.local
|
||||||
|
|
||||||
// TODO Add your events or remove the file completely if no events
|
import android.net.Uri
|
||||||
sealed interface MediaViewerEvents {
|
|
||||||
object MyEvent : MediaViewerEvents
|
interface LocalMediaFactory {
|
||||||
|
fun createFromUri(uri: Uri?, mimeType: String?): LocalMedia?
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.media.local
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.net.Uri
|
||||||
|
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import io.element.android.libraries.designsystem.components.ZoomableBox
|
||||||
|
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||||
|
|
||||||
|
@SuppressLint("UnsafeOptInUsageError")
|
||||||
|
@Composable
|
||||||
|
fun LocalMediaView(
|
||||||
|
localMedia: LocalMedia,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
MimeTypes.isImage(localMedia.mimeType) -> MediaImageView(
|
||||||
|
uri = localMedia.uri,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
MimeTypes.isVideo(localMedia.mimeType) -> MediaVideoView(
|
||||||
|
uri = localMedia.uri,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MediaImageView(
|
||||||
|
uri: Uri,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
ZoomableBox(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
modifier = Modifier
|
||||||
|
.zoomable()
|
||||||
|
.fillMaxSize(),
|
||||||
|
model = uri,
|
||||||
|
contentDescription = "Image",
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
|
@Composable
|
||||||
|
fun MediaVideoView(
|
||||||
|
uri: Uri,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val exoPlayer = ExoPlayer.Builder(LocalContext.current).build()
|
||||||
|
val mediaItem = MediaItem.Builder()
|
||||||
|
.setUri(uri)
|
||||||
|
.build()
|
||||||
|
exoPlayer.playWhenReady
|
||||||
|
exoPlayer.setMediaItem(mediaItem)
|
||||||
|
exoPlayer.prepare()
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
factory = {
|
||||||
|
PlayerView(context).apply {
|
||||||
|
player = exoPlayer
|
||||||
|
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
|
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
|
||||||
|
}
|
||||||
|
}, modifier = modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
|
||||||
|
OnLifecycleEvent { _, event ->
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_RESUME -> exoPlayer.play()
|
||||||
|
Lifecycle.Event.ON_PAUSE -> exoPlayer.pause()
|
||||||
|
Lifecycle.Event.ON_DESTROY -> exoPlayer.release()
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,25 +24,32 @@ import com.bumble.appyx.core.plugin.Plugin
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.features.messages.impl.media.viewer.model.MediaContentUiModel
|
|
||||||
import io.element.android.libraries.architecture.NodeInputs
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
import io.element.android.libraries.architecture.inputs
|
import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.matrix.api.media.MatrixMediaSource
|
||||||
|
|
||||||
@ContributesNode(RoomScope::class)
|
@ContributesNode(RoomScope::class)
|
||||||
class MediaViewerNode @AssistedInject constructor(
|
class MediaViewerNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
|
presenterFactory: MediaViewerPresenter.Factory,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
data class Inputs(val mediaContent: MediaContentUiModel) : NodeInputs
|
data class Inputs(
|
||||||
|
val name: String,
|
||||||
|
val mediaSource: MatrixMediaSource,
|
||||||
|
) : NodeInputs
|
||||||
|
|
||||||
private val inputs: Inputs = inputs()
|
private val inputs: Inputs = inputs()
|
||||||
|
|
||||||
|
private val presenter = presenterFactory.create(inputs.name, inputs.mediaSource)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
|
val state = presenter.present()
|
||||||
MediaViewerView(
|
MediaViewerView(
|
||||||
state = MediaViewerState(inputs.mediaContent),
|
state = state,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.media.viewer
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.features.messages.impl.media.local.LocalMedia
|
||||||
|
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.media.MatrixMediaSource
|
||||||
|
|
||||||
|
class MediaViewerPresenter @AssistedInject constructor(
|
||||||
|
@Assisted private val name: String,
|
||||||
|
@Assisted private val mediaSource: MatrixMediaSource,
|
||||||
|
private val localMediaFactory: LocalMediaFactory,
|
||||||
|
private val client: MatrixClient,
|
||||||
|
) : Presenter<MediaViewerState> {
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(name: String, mediaSource: MatrixMediaSource): MediaViewerPresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): MediaViewerState {
|
||||||
|
val localMedia by produceState<Async<LocalMedia>>(initialValue = Async.Uninitialized) {
|
||||||
|
value = Async.Loading(null)
|
||||||
|
//TODO we are missing some permissions to use this API
|
||||||
|
client.mediaLoader.loadMediaFile(mediaSource, null)
|
||||||
|
.onSuccess {
|
||||||
|
val localMedia = localMediaFactory.createFromUri(uri = it, null)
|
||||||
|
Async.Success(localMedia)
|
||||||
|
}.onFailure {
|
||||||
|
Async.Failure(it, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaViewerState(
|
||||||
|
name = name,
|
||||||
|
downloadedMedia = localMedia,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,8 +16,10 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.media.viewer
|
package io.element.android.features.messages.impl.media.viewer
|
||||||
|
|
||||||
import io.element.android.features.messages.impl.media.viewer.model.MediaContentUiModel
|
import io.element.android.features.messages.impl.media.local.LocalMedia
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
|
||||||
data class MediaViewerState(
|
data class MediaViewerState(
|
||||||
val mediaContent: MediaContentUiModel
|
val name: String,
|
||||||
|
val downloadedMedia: Async<LocalMedia>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,22 +17,17 @@
|
||||||
package io.element.android.features.messages.impl.media.viewer
|
package io.element.android.features.messages.impl.media.viewer
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.messages.impl.media.viewer.model.MediaContentUiModel
|
import io.element.android.libraries.architecture.Async
|
||||||
|
|
||||||
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
|
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
|
||||||
override val values: Sequence<MediaViewerState>
|
override val values: Sequence<MediaViewerState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aMediaViewerState(),
|
aMediaViewerState(),
|
||||||
|
// Add other states here
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aMediaViewerState() = MediaViewerState(
|
fun aMediaViewerState() = MediaViewerState(
|
||||||
mediaContent = aMediaImage(),
|
name = "A media",
|
||||||
|
downloadedMedia = Async.Uninitialized
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun aMediaImage() = MediaContentUiModel.Image(
|
|
||||||
body = "a body",
|
|
||||||
url = "",
|
|
||||||
blurhash = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,84 +14,50 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.media.viewer
|
package io.element.android.features.messages.impl.media.viewer
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
|
||||||
import androidx.compose.foundation.gestures.calculatePan
|
|
||||||
import androidx.compose.foundation.gestures.calculateZoom
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import io.element.android.features.messages.impl.media.viewer.model.MediaContentUiModel
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.libraries.designsystem.components.ZoomableBox
|
import io.element.android.features.messages.impl.media.local.LocalMediaView
|
||||||
import io.element.android.libraries.designsystem.components.blurhash.BlurHashAsyncImage
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaViewerView(
|
fun MediaViewerView(
|
||||||
state: MediaViewerState,
|
state: MediaViewerState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Box(
|
Scaffold(modifier) {
|
||||||
modifier = modifier
|
Box(
|
||||||
.fillMaxSize()
|
modifier = Modifier.fillMaxSize(),
|
||||||
.background(MaterialTheme.colorScheme.background),
|
contentAlignment = Alignment.Center
|
||||||
contentAlignment = Alignment.Center,
|
) {
|
||||||
) {
|
when (state.downloadedMedia) {
|
||||||
when (state.mediaContent) {
|
is Async.Success -> LocalMediaView(state.downloadedMedia.state)
|
||||||
is MediaContentUiModel.Image -> MediaImageViewer(state.mediaContent)
|
is Async.Failure -> ErrorDialog(
|
||||||
is MediaContentUiModel.Video -> MediaVideoViewer(state.mediaContent)
|
content = "Error while downloading the media",
|
||||||
|
)
|
||||||
|
else -> CircularProgressIndicator(
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MediaImageViewer(
|
|
||||||
image: MediaContentUiModel.Image,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
ZoomableBox(
|
|
||||||
modifier = modifier.fillMaxSize(),
|
|
||||||
) {
|
|
||||||
BlurHashAsyncImage(
|
|
||||||
blurHash = image.blurhash,
|
|
||||||
modifier = Modifier.fillMaxSize().zoomable(),
|
|
||||||
model = image.mediaRequestData,
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MediaVideoViewer(
|
|
||||||
video: MediaContentUiModel.Video,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaViewerViewLightPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) =
|
fun MediaViewerViewLightPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) =
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,10 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.textcomposer
|
package io.element.android.features.messages.impl.textcomposer
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
|
|
||||||
|
@Immutable
|
||||||
sealed interface MessageComposerEvents {
|
sealed interface MessageComposerEvents {
|
||||||
object ToggleFullScreenState : MessageComposerEvents
|
object ToggleFullScreenState : MessageComposerEvents
|
||||||
data class SendMessage(val message: String) : MessageComposerEvents
|
data class SendMessage(val message: String) : MessageComposerEvents
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.textcomposer
|
package io.element.android.features.messages.impl.textcomposer
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.net.Uri
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
|
@ -25,6 +27,9 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
|
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.data.StableCharSequence
|
import io.element.android.libraries.core.data.StableCharSequence
|
||||||
import io.element.android.libraries.core.data.toStableCharSequence
|
import io.element.android.libraries.core.data.toStableCharSequence
|
||||||
|
|
@ -35,9 +40,9 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.mediapickers.PickerProvider
|
import io.element.android.libraries.mediapickers.PickerProvider
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@SingleIn(RoomScope::class)
|
@SingleIn(RoomScope::class)
|
||||||
|
|
@ -46,27 +51,32 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
private val mediaPickerProvider: PickerProvider,
|
private val mediaPickerProvider: PickerProvider,
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
|
private val localMediaFactory: LocalMediaFactory,
|
||||||
) : Presenter<MessageComposerState> {
|
) : Presenter<MessageComposerState> {
|
||||||
|
|
||||||
|
@SuppressLint("UnsafeOptInUsageError")
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): MessageComposerState {
|
override fun present(): MessageComposerState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri ->
|
val attachmentsState = remember {
|
||||||
Timber.d("Media picked from $uri")
|
mutableStateOf<AttachmentsState>(AttachmentsState.None)
|
||||||
})
|
}
|
||||||
|
|
||||||
val filesPicker = mediaPickerProvider.registerFilePicker(onResult = { uri ->
|
fun handlePickedMedia(uri: Uri?, mimeType: String? = null) {
|
||||||
Timber.d("File picked from $uri")
|
val localMedia = localMediaFactory.createFromUri(uri, mimeType)
|
||||||
})
|
attachmentsState.value = if (localMedia == null) {
|
||||||
|
AttachmentsState.None
|
||||||
|
} else {
|
||||||
|
val mediaAttachment = Attachment.Media(localMedia)
|
||||||
|
AttachmentsState.Previewing(persistentListOf(mediaAttachment))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(onResult = { uri ->
|
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { handlePickedMedia(it) })
|
||||||
Timber.d("Photo saved at $uri")
|
val filesPicker = mediaPickerProvider.registerFilePicker(onResult = { handlePickedMedia(it) })
|
||||||
})
|
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(onResult = { handlePickedMedia(it, MimeTypes.IMAGE_JPEG) }, deleteAfter = false)
|
||||||
|
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker(onResult = { handlePickedMedia(it, MimeTypes.VIDEO_MP4) }, deleteAfter = false)
|
||||||
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker(onResult = { uri ->
|
|
||||||
Timber.d("Video saved at $uri")
|
|
||||||
})
|
|
||||||
|
|
||||||
val isFullScreen = rememberSaveable {
|
val isFullScreen = rememberSaveable {
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
|
|
@ -129,6 +139,7 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
isFullScreen = isFullScreen.value,
|
isFullScreen = isFullScreen.value,
|
||||||
mode = composerMode.value,
|
mode = composerMode.value,
|
||||||
attachmentSourcePicker = attachmentSourcePicker,
|
attachmentSourcePicker = attachmentSourcePicker,
|
||||||
|
attachmentsState = attachmentsState.value,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,10 @@
|
||||||
package io.element.android.features.messages.impl.textcomposer
|
package io.element.android.features.messages.impl.textcomposer
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.libraries.core.data.StableCharSequence
|
import io.element.android.libraries.core.data.StableCharSequence
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class MessageComposerState(
|
data class MessageComposerState(
|
||||||
|
|
@ -26,11 +28,18 @@ data class MessageComposerState(
|
||||||
val isFullScreen: Boolean,
|
val isFullScreen: Boolean,
|
||||||
val mode: MessageComposerMode,
|
val mode: MessageComposerMode,
|
||||||
val attachmentSourcePicker: AttachmentSourcePicker?,
|
val attachmentSourcePicker: AttachmentSourcePicker?,
|
||||||
|
val attachmentsState: AttachmentsState,
|
||||||
val eventSink: (MessageComposerEvents) -> Unit
|
val eventSink: (MessageComposerEvents) -> Unit
|
||||||
) {
|
) {
|
||||||
val isSendButtonVisible: Boolean = text?.charSequence.isNullOrEmpty().not()
|
val isSendButtonVisible: Boolean = text?.charSequence.isNullOrEmpty().not()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
sealed interface AttachmentsState {
|
||||||
|
object None : AttachmentsState
|
||||||
|
data class Previewing(val attachments: ImmutableList<Attachment>) : AttachmentsState
|
||||||
|
}
|
||||||
|
|
||||||
sealed interface AttachmentSourcePicker {
|
sealed interface AttachmentSourcePicker {
|
||||||
object AllMedia : AttachmentSourcePicker
|
object AllMedia : AttachmentSourcePicker
|
||||||
object Camera : AttachmentSourcePicker
|
object Camera : AttachmentSourcePicker
|
||||||
|
|
|
||||||
|
|
@ -32,5 +32,6 @@ fun aMessageComposerState() = MessageComposerState(
|
||||||
isFullScreen = false,
|
isFullScreen = false,
|
||||||
mode = MessageComposerMode.Normal(content = ""),
|
mode = MessageComposerMode.Normal(content = ""),
|
||||||
attachmentSourcePicker = null,
|
attachmentSourcePicker = null,
|
||||||
|
attachmentsState = AttachmentsState.None,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ recyclerview = "1.3.0"
|
||||||
lifecycle = "2.6.1"
|
lifecycle = "2.6.1"
|
||||||
activity = "1.7.1"
|
activity = "1.7.1"
|
||||||
startup = "1.1.1"
|
startup = "1.1.1"
|
||||||
|
media3 = "1.0.1"
|
||||||
|
|
||||||
# Compose
|
# Compose
|
||||||
compose_bom = "2023.04.01"
|
compose_bom = "2023.04.01"
|
||||||
|
|
@ -69,6 +70,8 @@ androidx_lifecycle_runtime = { module = "androidx.lifecycle:lifecycle-runtime-kt
|
||||||
androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
|
androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
|
||||||
androidx_splash = "androidx.core:core-splashscreen:1.0.1"
|
androidx_splash = "androidx.core:core-splashscreen:1.0.1"
|
||||||
androidx_security_crypto = "androidx.security:security-crypto:1.0.0"
|
androidx_security_crypto = "androidx.security:security-crypto:1.0.0"
|
||||||
|
androidx_media3_exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
||||||
|
androidx_media3_ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
|
||||||
|
|
||||||
androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" }
|
androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" }
|
||||||
androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
|
androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue