Merge pull request #433 from vector-im/feature/fga/image_loading
Send and receive media
This commit is contained in:
commit
3f2e042aa5
170 changed files with 3591 additions and 679 deletions
|
|
@ -39,8 +39,6 @@ import io.element.android.libraries.matrix.api.createroom.RoomPreset
|
||||||
import io.element.android.libraries.matrix.api.createroom.RoomVisibility
|
import io.element.android.libraries.matrix.api.createroom.RoomVisibility
|
||||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
import io.element.android.libraries.mediaupload.api.MediaType
|
|
||||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -114,7 +112,7 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||||
createRoomAction: MutableState<Async<RoomId>>
|
createRoomAction: MutableState<Async<RoomId>>
|
||||||
) = launch {
|
) = launch {
|
||||||
suspend {
|
suspend {
|
||||||
val mxc = config.avatarUri?.let { uploadAvatar(it) }
|
val avatarUrl = config.avatarUri?.let { uploadAvatar(it) }
|
||||||
val params = CreateRoomParameters(
|
val params = CreateRoomParameters(
|
||||||
name = config.roomName,
|
name = config.roomName,
|
||||||
topic = config.topic,
|
topic = config.topic,
|
||||||
|
|
@ -123,16 +121,16 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||||
visibility = if (config.privacy == RoomPrivacy.Public) RoomVisibility.PUBLIC else RoomVisibility.PRIVATE,
|
visibility = if (config.privacy == RoomPrivacy.Public) RoomVisibility.PUBLIC else RoomVisibility.PRIVATE,
|
||||||
preset = if (config.privacy == RoomPrivacy.Public) RoomPreset.PUBLIC_CHAT else RoomPreset.PRIVATE_CHAT,
|
preset = if (config.privacy == RoomPrivacy.Public) RoomPreset.PUBLIC_CHAT else RoomPreset.PRIVATE_CHAT,
|
||||||
invite = config.invites.map { it.userId },
|
invite = config.invites.map { it.userId },
|
||||||
avatar = mxc,
|
avatar = avatarUrl,
|
||||||
)
|
)
|
||||||
matrixClient.createRoom(params).getOrThrow()
|
matrixClient.createRoom(params).getOrThrow()
|
||||||
.also { dataStore.clearCachedData() }
|
.also { dataStore.clearCachedData() }
|
||||||
}.execute(createRoomAction)
|
}.execute(createRoomAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun uploadAvatar(avatarUri: Uri): String? {
|
private suspend fun uploadAvatar(avatarUri: Uri): String {
|
||||||
val preprocessed = mediaPreProcessor.process(avatarUri, MediaType.Image).getOrThrow() as? MediaUploadInfo.Image
|
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
|
||||||
val byteArray = preprocessed?.file?.readBytes()
|
val byteArray = preprocessed.file.readBytes()
|
||||||
return byteArray?.let { matrixClient.uploadMedia(MimeTypes.Jpeg, it) }?.getOrThrow()
|
return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray).getOrThrow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ plugins {
|
||||||
id("io.element.android-compose-library")
|
id("io.element.android-compose-library")
|
||||||
alias(libs.plugins.anvil)
|
alias(libs.plugins.anvil)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
|
id("kotlin-parcelize")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
@ -51,6 +52,11 @@ 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)
|
||||||
|
implementation(libs.vanniktech.blurhash)
|
||||||
|
implementation(libs.telephoto.zoomableimage)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
testImplementation(libs.coroutines.test)
|
testImplementation(libs.coroutines.test)
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,10 @@ package io.element.android.features.messages.impl
|
||||||
|
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.features.messages.api.MessagesEntryPoint
|
import io.element.android.features.messages.api.MessagesEntryPoint
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
|
|
@ -33,6 +31,6 @@ class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint {
|
||||||
buildContext: BuildContext,
|
buildContext: BuildContext,
|
||||||
callback: MessagesEntryPoint.Callback
|
callback: MessagesEntryPoint.Callback
|
||||||
): Node {
|
): Node {
|
||||||
return parentNode.createNode<MessagesNode>(buildContext, listOf(callback))
|
return parentNode.createNode<MessagesFlowNode>(buildContext, listOf(callback))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.composable.Children
|
||||||
|
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.core.plugin.plugins
|
||||||
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
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.timeline.model.TimelineItem
|
||||||
|
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.createNode
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
@ContributesNode(RoomScope::class)
|
||||||
|
class MessagesFlowNode @AssistedInject constructor(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
) : BackstackNode<MessagesFlowNode.NavTarget>(
|
||||||
|
backstack = BackStack(
|
||||||
|
initialElement = NavTarget.Messages,
|
||||||
|
savedStateMap = buildContext.savedStateMap,
|
||||||
|
),
|
||||||
|
buildContext = buildContext,
|
||||||
|
plugins = plugins
|
||||||
|
) {
|
||||||
|
|
||||||
|
sealed interface NavTarget : Parcelable {
|
||||||
|
@Parcelize
|
||||||
|
object Messages : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class MediaViewer(
|
||||||
|
val title: String,
|
||||||
|
val mediaSource: MediaSource,
|
||||||
|
val thumbnailSource: MediaSource?,
|
||||||
|
val mimeType: String?,
|
||||||
|
) : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class AttachmentPreview(val attachment: Attachment) : NavTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
|
||||||
|
|
||||||
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
|
return when (navTarget) {
|
||||||
|
is NavTarget.Messages -> {
|
||||||
|
val callback = object : MessagesNode.Callback {
|
||||||
|
override fun onRoomDetailsClicked() {
|
||||||
|
callback?.onRoomDetailsClicked()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEventClicked(event: TimelineItem.Event) {
|
||||||
|
processEventClicked(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
|
||||||
|
backstack.push(NavTarget.AttachmentPreview(attachments.first()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createNode<MessagesNode>(buildContext, listOf(callback))
|
||||||
|
}
|
||||||
|
is NavTarget.MediaViewer -> {
|
||||||
|
val inputs = MediaViewerNode.Inputs(
|
||||||
|
name = navTarget.title,
|
||||||
|
mediaSource = navTarget.mediaSource,
|
||||||
|
thumbnailSource = navTarget.thumbnailSource,
|
||||||
|
mimeType = navTarget.mimeType,
|
||||||
|
)
|
||||||
|
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) {
|
||||||
|
when (event.content) {
|
||||||
|
is TimelineItemImageContent -> {
|
||||||
|
val navTarget = NavTarget.MediaViewer(
|
||||||
|
title = event.content.body,
|
||||||
|
mediaSource = event.content.mediaSource,
|
||||||
|
thumbnailSource = event.content.mediaSource,
|
||||||
|
mimeType = event.content.mimeType
|
||||||
|
)
|
||||||
|
backstack.push(navTarget)
|
||||||
|
}
|
||||||
|
is TimelineItemVideoContent -> {
|
||||||
|
val mediaSource = event.content.videoSource
|
||||||
|
val navTarget = NavTarget.MediaViewer(
|
||||||
|
title = event.content.body,
|
||||||
|
mediaSource = mediaSource,
|
||||||
|
thumbnailSource = event.content.thumbnailSource,
|
||||||
|
mimeType = event.content.mimeType,
|
||||||
|
)
|
||||||
|
backstack.push(navTarget)
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
Children(
|
||||||
|
navModel = backstack,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.api.MessagesEntryPoint
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
|
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(
|
||||||
|
|
@ -35,12 +37,26 @@ class MessagesNode @AssistedInject constructor(
|
||||||
private val presenter: MessagesPresenter,
|
private val presenter: MessagesPresenter,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
|
private val callback = plugins<Callback>().firstOrNull()
|
||||||
|
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun onRoomDetailsClicked()
|
||||||
|
fun onEventClicked(event: TimelineItem.Event)
|
||||||
|
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
|
||||||
|
}
|
||||||
|
|
||||||
private fun onRoomDetailsClicked() {
|
private fun onRoomDetailsClicked() {
|
||||||
callback?.onRoomDetailsClicked()
|
callback?.onRoomDetailsClicked()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onEventClicked(event: TimelineItem.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()
|
||||||
|
|
@ -48,7 +64,9 @@ class MessagesNode @AssistedInject constructor(
|
||||||
state = state,
|
state = state,
|
||||||
onBackPressed = this::navigateUp,
|
onBackPressed = this::navigateUp,
|
||||||
onRoomDetailsClicked = this::onRoomDetailsClicked,
|
onRoomDetailsClicked = this::onRoomDetailsClicked,
|
||||||
modifier = modifier
|
onEventClicked = this::onEventClicked,
|
||||||
|
onPreviewAttachments = this::onPreviewAttachments,
|
||||||
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||||
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.textcomposer.MessageComposerEvents
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
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.TimelinePresenter
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ package io.element.android.features.messages.impl
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,7 @@ package io.element.android.features.messages.impl
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.messages.impl.actionlist.anActionListState
|
import io.element.android.features.messages.impl.actionlist.anActionListState
|
||||||
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker
|
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
||||||
import io.element.android.features.messages.impl.textcomposer.aMessageComposerState
|
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemList
|
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.aTimelineState
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||||
|
|
@ -33,8 +32,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aMessagesState(),
|
aMessagesState(),
|
||||||
aMessagesState().copy(hasNetworkConnection = false),
|
aMessagesState().copy(hasNetworkConnection = false),
|
||||||
aMessagesState().copy(composerState = aMessageComposerState().copy(attachmentSourcePicker = AttachmentSourcePicker.AllMedia)),
|
aMessagesState().copy(composerState = aMessageComposerState().copy(showAttachmentSourcePicker = true)),
|
||||||
aMessagesState().copy(composerState = aMessageComposerState().copy(attachmentSourcePicker = AttachmentSourcePicker.Camera)),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,10 @@ import androidx.compose.material.ListItem
|
||||||
import androidx.compose.material.ModalBottomSheetValue
|
import androidx.compose.material.ModalBottomSheetValue
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
|
import androidx.compose.material.icons.filled.Collections
|
||||||
|
import androidx.compose.material.icons.filled.PhotoCamera
|
||||||
|
import androidx.compose.material.icons.filled.Videocam
|
||||||
import androidx.compose.material.rememberModalBottomSheetState
|
import androidx.compose.material.rememberModalBottomSheetState
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
|
@ -60,14 +64,16 @@ 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.textcomposer.AttachmentSourcePicker
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerEvents
|
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
||||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerView
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||||
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
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.TimelineView
|
||||||
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.networkmonitor.api.ui.ConnectivityIndicatorView
|
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
||||||
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||||
|
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
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.AvatarData
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
|
@ -79,23 +85,27 @@ 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
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringsR
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MessagesView(
|
fun MessagesView(
|
||||||
state: MessagesState,
|
state: MessagesState,
|
||||||
|
onBackPressed: () -> Unit,
|
||||||
|
onRoomDetailsClicked: () -> Unit,
|
||||||
|
onEventClicked: (event: TimelineItem.Event) -> Unit,
|
||||||
|
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onBackPressed: () -> Unit = {},
|
|
||||||
onRoomDetailsClicked: () -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
||||||
val itemActionsBottomSheetState = rememberModalBottomSheetState(
|
val itemActionsBottomSheetState = rememberModalBottomSheetState(
|
||||||
initialValue = ModalBottomSheetValue.Hidden,
|
initialValue = ModalBottomSheetValue.Hidden,
|
||||||
)
|
)
|
||||||
val composerState = state.composerState
|
val composerState = state.composerState
|
||||||
val initialBottomSheetState = if (LocalInspectionMode.current && composerState.attachmentSourcePicker != null) {
|
val initialBottomSheetState = if (LocalInspectionMode.current && composerState.showAttachmentSourcePicker) {
|
||||||
ModalBottomSheetValue.Expanded
|
ModalBottomSheetValue.Expanded
|
||||||
} else {
|
} else {
|
||||||
ModalBottomSheetValue.Hidden
|
ModalBottomSheetValue.Hidden
|
||||||
|
|
@ -103,6 +113,8 @@ fun MessagesView(
|
||||||
val bottomSheetState = rememberModalBottomSheetState(initialValue = initialBottomSheetState)
|
val bottomSheetState = rememberModalBottomSheetState(initialValue = initialBottomSheetState)
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
|
||||||
|
|
||||||
BackHandler(enabled = bottomSheetState.isVisible) {
|
BackHandler(enabled = bottomSheetState.isVisible) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
bottomSheetState.hide()
|
bottomSheetState.hide()
|
||||||
|
|
@ -129,6 +141,7 @@ fun MessagesView(
|
||||||
|
|
||||||
fun onMessageClicked(event: TimelineItem.Event) {
|
fun onMessageClicked(event: TimelineItem.Event) {
|
||||||
Timber.v("OnMessageClicked= ${event.id}")
|
Timber.v("OnMessageClicked= ${event.id}")
|
||||||
|
onEventClicked(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onMessageLongClicked(event: TimelineItem.Event) {
|
fun onMessageLongClicked(event: TimelineItem.Event) {
|
||||||
|
|
@ -149,8 +162,8 @@ fun MessagesView(
|
||||||
state.eventSink(MessagesEvents.HandleAction(action, event))
|
state.eventSink(MessagesEvents.HandleAction(action, event))
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(composerState.attachmentSourcePicker) {
|
LaunchedEffect(composerState.showAttachmentSourcePicker) {
|
||||||
if (composerState.attachmentSourcePicker != null) {
|
if (composerState.showAttachmentSourcePicker) {
|
||||||
// We need to use this instead of `LocalFocusManager.clearFocus()` to hide the keyboard when focus is on an Android View
|
// We need to use this instead of `LocalFocusManager.clearFocus()` to hide the keyboard when focus is on an Android View
|
||||||
localView.hideKeyboard()
|
localView.hideKeyboard()
|
||||||
bottomSheetState.show()
|
bottomSheetState.show()
|
||||||
|
|
@ -168,8 +181,7 @@ fun MessagesView(
|
||||||
sheetState = bottomSheetState,
|
sheetState = bottomSheetState,
|
||||||
displayHandle = true,
|
displayHandle = true,
|
||||||
sheetContent = {
|
sheetContent = {
|
||||||
MediaPickerMenu(
|
AttachmentSourcePickerMenu(
|
||||||
addAttachmentSourcePicker = composerState.attachmentSourcePicker,
|
|
||||||
eventSink = composerState.eventSink
|
eventSink = composerState.eventSink
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -215,6 +227,20 @@ fun MessagesView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AttachmentStateView(
|
||||||
|
state: AttachmentsState,
|
||||||
|
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit
|
||||||
|
) {
|
||||||
|
when (state) {
|
||||||
|
AttachmentsState.None -> Unit
|
||||||
|
is AttachmentsState.Previewing -> LaunchedEffect(state) {
|
||||||
|
onPreviewAttachments(state.attachments)
|
||||||
|
}
|
||||||
|
is AttachmentsState.Sending -> ProgressDialog(text = stringResource(id = StringsR.string.common_loading))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessagesViewContent(
|
fun MessagesViewContent(
|
||||||
state: MessagesState,
|
state: MessagesState,
|
||||||
|
|
@ -289,50 +315,33 @@ fun MessagesViewTopBar(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun MediaPickerMenu(
|
|
||||||
addAttachmentSourcePicker: AttachmentSourcePicker?,
|
|
||||||
eventSink: (MessageComposerEvents) -> Unit,
|
|
||||||
) {
|
|
||||||
when (addAttachmentSourcePicker) {
|
|
||||||
null -> return
|
|
||||||
AttachmentSourcePicker.AllMedia -> AllMediaSourcePickerMenu(eventSink = eventSink)
|
|
||||||
AttachmentSourcePicker.Camera -> CameraSourcePickerMenu(eventSink = eventSink)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AllMediaSourcePickerMenu(
|
internal fun AttachmentSourcePickerMenu(
|
||||||
eventSink: (MessageComposerEvents) -> Unit,
|
eventSink: (MessageComposerEvents) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) }) {
|
ListItem(
|
||||||
Text(stringResource(R.string.screen_room_attachment_source_gallery))
|
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
|
||||||
}
|
icon = { Icon(Icons.Default.Collections, null) },
|
||||||
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) }) {
|
text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
|
||||||
Text(stringResource(R.string.screen_room_attachment_source_files))
|
)
|
||||||
}
|
ListItem(
|
||||||
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromCamera) }) {
|
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
|
||||||
Text(stringResource(R.string.screen_room_attachment_source_camera))
|
icon = { Icon(Icons.Default.AttachFile, null) },
|
||||||
}
|
text = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
|
||||||
}
|
)
|
||||||
}
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
icon = { Icon(Icons.Default.PhotoCamera, null) },
|
||||||
@Composable
|
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
|
||||||
internal fun CameraSourcePickerMenu(
|
)
|
||||||
eventSink: (MessageComposerEvents) -> Unit,
|
ListItem(
|
||||||
modifier: Modifier = Modifier,
|
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
|
||||||
) {
|
icon = { Icon(Icons.Default.Videocam, null) },
|
||||||
Column(modifier) {
|
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
|
||||||
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickCameraAttachmentSource.Photo) }) {
|
)
|
||||||
Text(stringResource(R.string.screen_room_attachment_source_camera_photo))
|
|
||||||
}
|
|
||||||
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickCameraAttachmentSource.Video) }) {
|
|
||||||
Text(stringResource(R.string.screen_room_attachment_source_camera_video))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -348,5 +357,11 @@ internal fun MessagesViewDarkPreview(@PreviewParameter(MessagesStateProvider::cl
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview(state: MessagesState) {
|
private fun ContentToPreview(state: MessagesState) {
|
||||||
MessagesView(state)
|
MessagesView(
|
||||||
|
state = state,
|
||||||
|
onBackPressed = {},
|
||||||
|
onRoomDetailsClicked = {},
|
||||||
|
onEventClicked = {},
|
||||||
|
onPreviewAttachments = {}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, val compressIfPossible: Boolean) : Attachment
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* 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.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
sealed interface AttachmentsPreviewEvents {
|
||||||
|
object SendAttachment : AttachmentsPreviewEvents
|
||||||
|
object ClearSendState : AttachmentsPreviewEvents
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
/*
|
||||||
|
* 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.designsystem.theme.ForcedDarkElementTheme
|
||||||
|
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) {
|
||||||
|
ForcedDarkElementTheme {
|
||||||
|
val state = presenter.present()
|
||||||
|
AttachmentsPreviewView(
|
||||||
|
state = state,
|
||||||
|
onDismiss = this::navigateUp,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
/*
|
||||||
|
* 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.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
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.Async
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.architecture.executeResult
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
|
@Assisted private val attachment: Attachment,
|
||||||
|
private val mediaSender: MediaSender,
|
||||||
|
) : Presenter<AttachmentsPreviewState> {
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(attachment: Attachment): AttachmentsPreviewPresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): AttachmentsPreviewState {
|
||||||
|
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val sendActionState = remember {
|
||||||
|
mutableStateOf<Async<Unit>>(Async.Uninitialized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
|
||||||
|
when (attachmentsPreviewEvents) {
|
||||||
|
AttachmentsPreviewEvents.SendAttachment -> coroutineScope.sendAttachment(attachment, sendActionState)
|
||||||
|
AttachmentsPreviewEvents.ClearSendState -> sendActionState.value = Async.Uninitialized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AttachmentsPreviewState(
|
||||||
|
attachment = attachment,
|
||||||
|
sendActionState = sendActionState.value,
|
||||||
|
eventSink = ::handleEvents
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.sendAttachment(
|
||||||
|
attachment: Attachment,
|
||||||
|
sendActionState: MutableState<Async<Unit>>,
|
||||||
|
) = launch {
|
||||||
|
when (attachment) {
|
||||||
|
is Attachment.Media -> {
|
||||||
|
sendMedia(
|
||||||
|
mediaAttachment = attachment,
|
||||||
|
sendActionState = sendActionState
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendMedia(
|
||||||
|
mediaAttachment: Attachment.Media,
|
||||||
|
sendActionState: MutableState<Async<Unit>>,
|
||||||
|
) {
|
||||||
|
suspend {
|
||||||
|
mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.mimeType, mediaAttachment.compressIfPossible)
|
||||||
|
}.executeResult(sendActionState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
|
||||||
|
data class AttachmentsPreviewState(
|
||||||
|
val attachment: Attachment,
|
||||||
|
val sendActionState: Async<Unit>,
|
||||||
|
val eventSink: (AttachmentsPreviewEvents) -> Unit
|
||||||
|
)
|
||||||
|
|
@ -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.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
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
|
||||||
|
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
|
||||||
|
override val values: Sequence<AttachmentsPreviewState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
anAttachmentsPreviewState(),
|
||||||
|
anAttachmentsPreviewState(sendActionState = Async.Loading()),
|
||||||
|
anAttachmentsPreviewState(sendActionState = Async.Failure(RuntimeException())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun anAttachmentsPreviewState(sendActionState: Async<Unit> = Async.Uninitialized) = AttachmentsPreviewState(
|
||||||
|
attachment = Attachment.Media(
|
||||||
|
localMedia = LocalMedia("path".toUri(), MimeTypes.Jpeg, "an image", 1000L),
|
||||||
|
compressIfPossible = true
|
||||||
|
),
|
||||||
|
sendActionState = sendActionState,
|
||||||
|
eventSink = {}
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
/*
|
||||||
|
* 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.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
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.features.messages.impl.attachments.Attachment
|
||||||
|
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
||||||
|
import io.element.android.features.messages.impl.media.local.LocalMediaView
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
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.TextButton
|
||||||
|
import io.element.android.libraries.ui.strings.R
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringsR
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AttachmentsPreviewView(
|
||||||
|
state: AttachmentsPreviewState,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun postSendAttachment() {
|
||||||
|
state.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun postClearSendState() {
|
||||||
|
state.eventSink(AttachmentsPreviewEvents.ClearSendState)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.sendActionState is Async.Success) {
|
||||||
|
LaunchedEffect(state.sendActionState) {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(modifier) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.padding(it),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
AttachmentPreviewContent(
|
||||||
|
attachment = state.attachment,
|
||||||
|
onSendClicked = ::postSendAttachment,
|
||||||
|
onDismiss = onDismiss
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AttachmentSendStateView(
|
||||||
|
sendActionState = state.sendActionState,
|
||||||
|
onRetryClicked = ::postSendAttachment,
|
||||||
|
onRetryDismissed = ::postClearSendState
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AttachmentSendStateView(
|
||||||
|
sendActionState: Async<Unit>,
|
||||||
|
onRetryDismissed: () -> Unit,
|
||||||
|
onRetryClicked: () -> Unit
|
||||||
|
) {
|
||||||
|
when (sendActionState) {
|
||||||
|
is Async.Loading -> {
|
||||||
|
ProgressDialog(text = stringResource(id = R.string.common_loading))
|
||||||
|
}
|
||||||
|
|
||||||
|
is Async.Failure -> {
|
||||||
|
RetryDialog(
|
||||||
|
content = stringResource(sendAttachmentError(sendActionState.error)),
|
||||||
|
onDismiss = onRetryDismissed,
|
||||||
|
onRetry = onRetryClicked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AttachmentPreviewContent(
|
||||||
|
attachment: Attachment,
|
||||||
|
onSendClicked: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = 24.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
) {
|
||||||
|
when (attachment) {
|
||||||
|
is Attachment.Media -> LocalMediaView(
|
||||||
|
localMedia = attachment.localMedia
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AttachmentsPreviewBottomActions(
|
||||||
|
onCancelClicked = onDismiss,
|
||||||
|
onSendClicked = onSendClicked,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.defaultMinSize(minHeight = 120.dp)
|
||||||
|
.padding(all = 24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AttachmentsPreviewBottomActions(
|
||||||
|
onCancelClicked: () -> Unit,
|
||||||
|
onSendClicked: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onCancelClicked) {
|
||||||
|
Text(stringResource(id = StringsR.string.action_cancel))
|
||||||
|
}
|
||||||
|
TextButton(onClick = onSendClicked) {
|
||||||
|
Text(stringResource(id = StringsR.string.action_send))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun AttachmentsPreviewViewDarkPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) =
|
||||||
|
ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview(state: AttachmentsPreviewState) {
|
||||||
|
AttachmentsPreviewView(
|
||||||
|
state = state,
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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.error
|
||||||
|
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
|
import io.element.android.libraries.ui.strings.R
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
* 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 androidx.core.net.toUri
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.androidutils.file.getFileName
|
||||||
|
import io.element.android.libraries.androidutils.file.getFileSize
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class AndroidLocalMediaFactory @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) : LocalMediaFactory {
|
||||||
|
|
||||||
|
override fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia {
|
||||||
|
val uri = mediaFile.path().toUri()
|
||||||
|
return createFromUri(uri, mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createFromUri(uri: Uri, mimeType: String?): LocalMedia {
|
||||||
|
val resolvedMimeType = mimeType ?: context.contentResolver.getType(uri) ?: MimeTypes.OctetStream
|
||||||
|
val fileName = context.getFileName(uri)
|
||||||
|
val fileSize = context.getFileSize(uri)
|
||||||
|
return LocalMedia(
|
||||||
|
uri = uri,
|
||||||
|
mimeType = resolvedMimeType,
|
||||||
|
name = fileName,
|
||||||
|
size = fileSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import kotlinx.parcelize.IgnoredOnParcel
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
@Immutable
|
||||||
|
data class LocalMedia(
|
||||||
|
val uri: Uri,
|
||||||
|
val mimeType: String,
|
||||||
|
val name: String?,
|
||||||
|
val size: Long,
|
||||||
|
) : Parcelable {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This tries to convert the uri to a file if applicable, otherwise keep it as uri.
|
||||||
|
*/
|
||||||
|
@IgnoredOnParcel val model: Any by lazy {
|
||||||
|
UriToFileMapper.map(uri) ?: uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||||
|
|
||||||
|
interface LocalMediaFactory {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method will create a [LocalMedia] with the given [MediaFile] and [mimeType].
|
||||||
|
*/
|
||||||
|
fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method will create a [LocalMedia] with the given [uri] and [mimeType]
|
||||||
|
* If the [mimeType] is null, it'll try to read it from the content.
|
||||||
|
*/
|
||||||
|
fun createFromUri(uri: Uri, mimeType: String?): LocalMedia
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
/*
|
||||||
|
* 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.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
|
import io.element.android.features.messages.impl.media.local.exoplayer.ExoPlayerWrapper
|
||||||
|
import io.element.android.libraries.designsystem.R
|
||||||
|
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||||
|
import me.saket.telephoto.zoomable.ZoomSpec
|
||||||
|
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
|
||||||
|
import me.saket.telephoto.zoomable.rememberZoomableImageState
|
||||||
|
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||||
|
|
||||||
|
@SuppressLint("UnsafeOptInUsageError")
|
||||||
|
@Composable
|
||||||
|
fun LocalMediaView(
|
||||||
|
localMedia: LocalMedia?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
mimeType: String? = localMedia?.mimeType,
|
||||||
|
onReady: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
MimeTypes.isImage(mimeType) -> MediaImageView(
|
||||||
|
localMedia = localMedia,
|
||||||
|
onReady = onReady,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
MimeTypes.isVideo(mimeType) -> MediaVideoView(
|
||||||
|
localMedia = localMedia,
|
||||||
|
onReady = onReady,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MediaImageView(
|
||||||
|
localMedia: LocalMedia?,
|
||||||
|
onReady: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (LocalInspectionMode.current) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.sample_background),
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val zoomableState = rememberZoomableState(
|
||||||
|
zoomSpec = ZoomSpec(maxZoomFactor = 3f)
|
||||||
|
)
|
||||||
|
val zoomableImageState = rememberZoomableImageState(zoomableState)
|
||||||
|
LaunchedEffect(zoomableImageState.isImageDisplayed) {
|
||||||
|
if (zoomableImageState.isImageDisplayed) {
|
||||||
|
onReady()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ZoomableAsyncImage(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
state = zoomableImageState,
|
||||||
|
model = localMedia?.model,
|
||||||
|
contentDescription = "Image",
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
|
@Composable
|
||||||
|
fun MediaVideoView(
|
||||||
|
localMedia: LocalMedia?,
|
||||||
|
onReady: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val playerListener = object : Player.Listener {
|
||||||
|
override fun onRenderedFirstFrame() {
|
||||||
|
onReady()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val exoPlayer = remember {
|
||||||
|
ExoPlayerWrapper.create(context)
|
||||||
|
.apply {
|
||||||
|
addListener(playerListener)
|
||||||
|
this.prepare()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (localMedia?.uri != null) {
|
||||||
|
LaunchedEffect(localMedia.uri) {
|
||||||
|
val mediaItem = MediaItem.fromUri(localMedia.uri)
|
||||||
|
exoPlayer.setMediaItem(mediaItem)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
exoPlayer.setMediaItems(emptyList())
|
||||||
|
}
|
||||||
|
AndroidView(
|
||||||
|
factory = {
|
||||||
|
PlayerView(context).apply {
|
||||||
|
player = exoPlayer
|
||||||
|
setShowPreviousButton(false)
|
||||||
|
setShowNextButton(false)
|
||||||
|
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
|
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
|
||||||
|
controllerShowTimeoutMs = 3000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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()
|
||||||
|
exoPlayer.removeListener(playerListener)
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
/*
|
||||||
|
* 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.ContentResolver
|
||||||
|
import android.net.Uri
|
||||||
|
import io.element.android.libraries.androidutils.uri.ASSET_FILE_PATH_ROOT
|
||||||
|
import io.element.android.libraries.androidutils.uri.firstPathSegment
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to convert a URI to a File.
|
||||||
|
* Extracted from Coil [coil.map.FileUriMapper]
|
||||||
|
*/
|
||||||
|
object UriToFileMapper {
|
||||||
|
|
||||||
|
fun map(data: Uri): File? {
|
||||||
|
if (!isApplicable(data)) return null
|
||||||
|
return if (data.scheme == ContentResolver.SCHEME_FILE) {
|
||||||
|
data.path?.let(::File)
|
||||||
|
} else {
|
||||||
|
// If the scheme is not "file", it's null, representing a literal path on disk.
|
||||||
|
// Assume the entire input, regardless of any reserved characters, is valid.
|
||||||
|
File(data.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isApplicable(data: Uri): Boolean {
|
||||||
|
return !isAssetUri(data) &&
|
||||||
|
data.scheme.let { it == null || it == ContentResolver.SCHEME_FILE } &&
|
||||||
|
data.path.orEmpty().startsWith('/') && data.firstPathSegment != null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAssetUri(uri: Uri): Boolean {
|
||||||
|
return uri.scheme == ContentResolver.SCHEME_FILE && uri.firstPathSegment == ASSET_FILE_PATH_ROOT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
* 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.exoplayer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around ExoPlayer to disable some commands.
|
||||||
|
* Necessary to hide the settings wheels from the player.
|
||||||
|
*/
|
||||||
|
@UnstableApi
|
||||||
|
class ExoPlayerWrapper(private val exoPlayer: ExoPlayer) : ExoPlayer by exoPlayer {
|
||||||
|
|
||||||
|
override fun isCommandAvailable(command: Int): Boolean {
|
||||||
|
return availableCommands.contains(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAvailableCommands(): Player.Commands {
|
||||||
|
return exoPlayer.availableCommands
|
||||||
|
.buildUpon()
|
||||||
|
.remove(Player.COMMAND_SET_TRACK_SELECTION_PARAMETERS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun create(context: Context): ExoPlayer {
|
||||||
|
return ExoPlayerWrapper(
|
||||||
|
ExoPlayer.Builder(context).build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,11 +14,9 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediaupload.api
|
package io.element.android.features.messages.impl.media.viewer
|
||||||
|
|
||||||
sealed interface MediaType {
|
sealed interface MediaViewerEvents {
|
||||||
object Image : MediaType
|
object RetryLoading : MediaViewerEvents
|
||||||
object Video : MediaType
|
object ClearLoadingError : MediaViewerEvents
|
||||||
object Audio : MediaType
|
|
||||||
object File : MediaType
|
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
/*
|
||||||
|
* 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.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.libraries.architecture.NodeInputs
|
||||||
|
import io.element.android.libraries.architecture.inputs
|
||||||
|
import io.element.android.libraries.designsystem.theme.ForcedDarkElementTheme
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
|
||||||
|
@ContributesNode(RoomScope::class)
|
||||||
|
class MediaViewerNode @AssistedInject constructor(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
presenterFactory: MediaViewerPresenter.Factory,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
|
data class Inputs(
|
||||||
|
val name: String,
|
||||||
|
val mediaSource: MediaSource,
|
||||||
|
val thumbnailSource: MediaSource?,
|
||||||
|
val mimeType: String?
|
||||||
|
) : NodeInputs
|
||||||
|
|
||||||
|
private val inputs: Inputs = inputs()
|
||||||
|
|
||||||
|
private val presenter = presenterFactory.create(inputs)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
ForcedDarkElementTheme {
|
||||||
|
val state = presenter.present()
|
||||||
|
MediaViewerView(
|
||||||
|
state = state,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
/*
|
||||||
|
* 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.DisposableEffect
|
||||||
|
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.setValue
|
||||||
|
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.media.MatrixMediaLoader
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class MediaViewerPresenter @AssistedInject constructor(
|
||||||
|
@Assisted private val inputs: MediaViewerNode.Inputs,
|
||||||
|
private val localMediaFactory: LocalMediaFactory,
|
||||||
|
private val mediaLoader: MatrixMediaLoader,
|
||||||
|
) : Presenter<MediaViewerState> {
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(inputs: MediaViewerNode.Inputs): MediaViewerPresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): MediaViewerState {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
var loadMediaTrigger by remember { mutableStateOf(0) }
|
||||||
|
val mediaFile: MutableState<MediaFile?> = remember {
|
||||||
|
mutableStateOf(null)
|
||||||
|
}
|
||||||
|
val localMedia: MutableState<Async<LocalMedia>> = remember {
|
||||||
|
mutableStateOf(Async.Uninitialized)
|
||||||
|
}
|
||||||
|
DisposableEffect(loadMediaTrigger) {
|
||||||
|
coroutineScope.downloadMedia(mediaFile, localMedia)
|
||||||
|
onDispose {
|
||||||
|
mediaFile.value?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleEvents(mediaViewerEvents: MediaViewerEvents) {
|
||||||
|
when (mediaViewerEvents) {
|
||||||
|
MediaViewerEvents.RetryLoading -> loadMediaTrigger++
|
||||||
|
MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaViewerState(
|
||||||
|
name = inputs.name,
|
||||||
|
mimeType = inputs.mimeType,
|
||||||
|
thumbnailSource = inputs.thumbnailSource,
|
||||||
|
downloadedMedia = localMedia.value,
|
||||||
|
eventSink = ::handleEvents
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.downloadMedia(mediaFile: MutableState<MediaFile?>, localMedia: MutableState<Async<LocalMedia>>) = launch {
|
||||||
|
localMedia.value = Async.Loading()
|
||||||
|
mediaLoader.downloadMediaFile(inputs.mediaSource, inputs.mimeType)
|
||||||
|
.onSuccess {
|
||||||
|
mediaFile.value = it
|
||||||
|
}.mapCatching {
|
||||||
|
localMediaFactory.createFromMediaFile(it, inputs.mimeType)
|
||||||
|
}.onSuccess {
|
||||||
|
localMedia.value = Async.Success(it)
|
||||||
|
}.onFailure {
|
||||||
|
localMedia.value = Async.Failure(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.viewer
|
||||||
|
|
||||||
|
import io.element.android.features.messages.impl.media.local.LocalMedia
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
|
||||||
|
data class MediaViewerState(
|
||||||
|
val name: String,
|
||||||
|
val mimeType: String?,
|
||||||
|
val thumbnailSource: MediaSource?,
|
||||||
|
val downloadedMedia: Async<LocalMedia>,
|
||||||
|
val eventSink: (MediaViewerEvents) -> Unit,
|
||||||
|
)
|
||||||
|
|
@ -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.media.viewer
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
|
import io.element.android.features.messages.impl.media.local.LocalMedia
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
|
||||||
|
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
|
||||||
|
override val values: Sequence<MediaViewerState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aMediaViewerState(),
|
||||||
|
aMediaViewerState(Async.Loading()),
|
||||||
|
aMediaViewerState(Async.Failure(IllegalStateException())),
|
||||||
|
aMediaViewerState(
|
||||||
|
Async.Success(
|
||||||
|
LocalMedia(
|
||||||
|
Uri.EMPTY, MimeTypes.IMAGE_JPEG, "an image file", 100L
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
aMediaViewerState(
|
||||||
|
Async.Success(
|
||||||
|
LocalMedia(
|
||||||
|
Uri.EMPTY, MimeTypes.VIDEO_MP4, "a video file", 100L
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aMediaViewerState(downloadedMedia: Async<LocalMedia> = Async.Uninitialized) = MediaViewerState(
|
||||||
|
name = "A media",
|
||||||
|
mimeType = MimeTypes.IMAGE_JPEG,
|
||||||
|
thumbnailSource = null,
|
||||||
|
downloadedMedia = downloadedMedia,
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
/*
|
||||||
|
* 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.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
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.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import io.element.android.features.messages.impl.media.local.LocalMediaView
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.architecture.isLoading
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
||||||
|
import io.element.android.libraries.designsystem.modifiers.roundedBackground
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MediaViewerView(
|
||||||
|
state: MediaViewerState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun onRetry() {
|
||||||
|
state.eventSink(MediaViewerEvents.RetryLoading)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDismissError() {
|
||||||
|
state.eventSink(MediaViewerEvents.ClearLoadingError)
|
||||||
|
}
|
||||||
|
|
||||||
|
var showProgress by remember {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trick to avoid showing progress indicator if the media is already on disk.
|
||||||
|
// When sdk will expose download progress we'll be able to remove this.
|
||||||
|
LaunchedEffect(state.downloadedMedia) {
|
||||||
|
showProgress = false
|
||||||
|
delay(100)
|
||||||
|
if (state.downloadedMedia.isLoading()) {
|
||||||
|
showProgress = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var showThumbnail by remember {
|
||||||
|
mutableStateOf(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onMediaReady() {
|
||||||
|
showThumbnail = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(modifier) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(it),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (state.downloadedMedia is Async.Failure) {
|
||||||
|
ErrorView(
|
||||||
|
errorMessage = stringResource(id = StringR.string.error_unknown),
|
||||||
|
onRetry = ::onRetry,
|
||||||
|
onDismiss = ::onDismissError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LocalMediaView(
|
||||||
|
localMedia = state.downloadedMedia.dataOrNull(),
|
||||||
|
mimeType = state.mimeType,
|
||||||
|
onReady = ::onMediaReady
|
||||||
|
)
|
||||||
|
ThumbnailView(
|
||||||
|
thumbnailSource = state.thumbnailSource,
|
||||||
|
showThumbnail = showThumbnail,
|
||||||
|
showProgress = showProgress,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThumbnailView(
|
||||||
|
thumbnailSource: MediaSource?,
|
||||||
|
showThumbnail: Boolean,
|
||||||
|
showProgress: Boolean,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showThumbnail,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut()
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
val mediaRequestData = MediaRequestData(
|
||||||
|
source = thumbnailSource,
|
||||||
|
kind = MediaRequestData.Kind.Content
|
||||||
|
)
|
||||||
|
AsyncImage(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
model = mediaRequestData,
|
||||||
|
alpha = 0.8f,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
if (showProgress) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.roundedBackground(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ErrorView(
|
||||||
|
errorMessage: String,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
RetryDialog(
|
||||||
|
modifier = modifier,
|
||||||
|
content = errorMessage,
|
||||||
|
onRetry = onRetry,
|
||||||
|
onDismiss = onDismiss
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun MediaViewerViewDarkPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) =
|
||||||
|
ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview(state: MediaViewerState) {
|
||||||
|
MediaViewerView(
|
||||||
|
state = state,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -14,10 +14,12 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.textcomposer
|
package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -28,11 +30,8 @@ sealed interface MessageComposerEvents {
|
||||||
object DismissAttachmentMenu : MessageComposerEvents
|
object DismissAttachmentMenu : MessageComposerEvents
|
||||||
sealed interface PickAttachmentSource : MessageComposerEvents {
|
sealed interface PickAttachmentSource : MessageComposerEvents {
|
||||||
object FromGallery : PickAttachmentSource
|
object FromGallery : PickAttachmentSource
|
||||||
object FromCamera : PickAttachmentSource
|
|
||||||
object FromFiles : PickAttachmentSource
|
object FromFiles : PickAttachmentSource
|
||||||
}
|
object PhotoFromCamera : PickAttachmentSource
|
||||||
sealed interface PickCameraAttachmentSource : MessageComposerEvents {
|
object VideoFromCamera : PickAttachmentSource
|
||||||
object Photo : PickCameraAttachmentSource
|
|
||||||
object Video : PickCameraAttachmentSource
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -14,8 +14,9 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.textcomposer
|
package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
@ -26,12 +27,14 @@ 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 androidx.media3.common.util.UnstableApi
|
||||||
|
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.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
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
|
@ -40,15 +43,13 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
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.api.PickerProvider
|
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
import io.element.android.libraries.mediaupload.api.MediaType
|
|
||||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
|
||||||
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
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
|
||||||
|
|
||||||
@SingleIn(RoomScope::class)
|
@SingleIn(RoomScope::class)
|
||||||
class MessageComposerPresenter @Inject constructor(
|
class MessageComposerPresenter @Inject constructor(
|
||||||
|
|
@ -56,44 +57,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 mediaPreProcessor: MediaPreProcessor,
|
private val localMediaFactory: LocalMediaFactory,
|
||||||
|
private val mediaSender: MediaSender,
|
||||||
private val snackbarDispatcher: SnackbarDispatcher,
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
) : 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, mimeType ->
|
val attachmentsState = remember {
|
||||||
if (uri == null) return@registerGalleryPicker
|
mutableStateOf<AttachmentsState>(AttachmentsState.None)
|
||||||
Timber.d("Media picked from $uri")
|
|
||||||
// We don't know which type of media was retrieved, so we need this check
|
|
||||||
val mediaType = when {
|
|
||||||
mimeType.isMimeTypeImage() -> MediaType.Image
|
|
||||||
mimeType.isMimeTypeVideo() -> MediaType.Video
|
|
||||||
else -> error("MimeType must be either image/* or video/*")
|
|
||||||
}
|
|
||||||
appCoroutineScope.sendMedia(uri, mediaType)
|
|
||||||
})
|
|
||||||
|
|
||||||
val filesPicker = mediaPickerProvider.registerFilePicker(mimeType = MimeTypes.Any) { uri ->
|
|
||||||
if (uri == null) return@registerFilePicker
|
|
||||||
Timber.d("File picked from $uri")
|
|
||||||
appCoroutineScope.sendMedia(uri, MediaType.File)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
|
||||||
|
handlePickedMedia(attachmentsState, uri, mimeType)
|
||||||
|
}
|
||||||
|
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri ->
|
||||||
|
handlePickedMedia(attachmentsState, uri, compressIfPossible = false)
|
||||||
|
}
|
||||||
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
|
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
|
||||||
if (uri == null) return@registerCameraPhotoPicker
|
handlePickedMedia(attachmentsState, uri, MimeTypes.IMAGE_JPEG)
|
||||||
Timber.d("Photo saved at $uri")
|
|
||||||
appCoroutineScope.sendMedia(uri, MediaType.Image, deleteOriginal = true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
|
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
|
||||||
if (uri == null) return@registerCameraVideoPicker
|
handlePickedMedia(attachmentsState, uri, MimeTypes.VIDEO_MP4)
|
||||||
Timber.d("Video saved at $uri")
|
|
||||||
appCoroutineScope.sendMedia(uri, MediaType.Video, deleteOriginal = true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val isFullScreen = rememberSaveable {
|
val isFullScreen = rememberSaveable {
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
}
|
}
|
||||||
|
|
@ -104,7 +93,7 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
mutableStateOf(MessageComposerMode.Normal(""))
|
mutableStateOf(MessageComposerMode.Normal(""))
|
||||||
}
|
}
|
||||||
|
|
||||||
var attachmentSourcePicker: AttachmentSourcePicker? by remember { mutableStateOf(null) }
|
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(composerMode.value) {
|
LaunchedEffect(composerMode.value) {
|
||||||
when (val modeValue = composerMode.value) {
|
when (val modeValue = composerMode.value) {
|
||||||
|
|
@ -113,6 +102,13 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(attachmentsState.value) {
|
||||||
|
when (val attachmentStateValue = attachmentsState.value) {
|
||||||
|
is AttachmentsState.Sending -> localCoroutineScope.sendAttachment(attachmentStateValue.attachments.first(), attachmentsState)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun handleEvents(event: MessageComposerEvents) {
|
fun handleEvents(event: MessageComposerEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
|
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
|
||||||
|
|
@ -124,27 +120,24 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
|
|
||||||
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode, text)
|
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode, text)
|
||||||
is MessageComposerEvents.SetMode -> composerMode.value = event.composerMode
|
is MessageComposerEvents.SetMode -> composerMode.value = event.composerMode
|
||||||
MessageComposerEvents.AddAttachment -> localCoroutineScope.ifMediaPickersEnabled {
|
MessageComposerEvents.AddAttachment -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||||
attachmentSourcePicker = AttachmentSourcePicker.AllMedia
|
showAttachmentSourcePicker = true
|
||||||
}
|
}
|
||||||
MessageComposerEvents.DismissAttachmentMenu -> attachmentSourcePicker = null
|
MessageComposerEvents.DismissAttachmentMenu -> showAttachmentSourcePicker = false
|
||||||
MessageComposerEvents.PickAttachmentSource.FromGallery -> localCoroutineScope.ifMediaPickersEnabled {
|
MessageComposerEvents.PickAttachmentSource.FromGallery -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||||
attachmentSourcePicker = null
|
showAttachmentSourcePicker = false
|
||||||
galleryMediaPicker.launch()
|
galleryMediaPicker.launch()
|
||||||
}
|
}
|
||||||
MessageComposerEvents.PickAttachmentSource.FromFiles -> localCoroutineScope.ifMediaPickersEnabled {
|
MessageComposerEvents.PickAttachmentSource.FromFiles -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||||
attachmentSourcePicker = null
|
showAttachmentSourcePicker = false
|
||||||
filesPicker.launch()
|
filesPicker.launch()
|
||||||
}
|
}
|
||||||
MessageComposerEvents.PickAttachmentSource.FromCamera -> localCoroutineScope.ifMediaPickersEnabled {
|
MessageComposerEvents.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||||
attachmentSourcePicker = AttachmentSourcePicker.Camera
|
showAttachmentSourcePicker = false
|
||||||
}
|
|
||||||
MessageComposerEvents.PickCameraAttachmentSource.Photo -> localCoroutineScope.ifMediaPickersEnabled {
|
|
||||||
attachmentSourcePicker = null
|
|
||||||
cameraPhotoPicker.launch()
|
cameraPhotoPicker.launch()
|
||||||
}
|
}
|
||||||
MessageComposerEvents.PickCameraAttachmentSource.Video -> localCoroutineScope.ifMediaPickersEnabled {
|
MessageComposerEvents.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||||
attachmentSourcePicker = null
|
showAttachmentSourcePicker = false
|
||||||
cameraVideoPicker.launch()
|
cameraVideoPicker.launch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -154,12 +147,13 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
text = text.value,
|
text = text.value,
|
||||||
isFullScreen = isFullScreen.value,
|
isFullScreen = isFullScreen.value,
|
||||||
mode = composerMode.value,
|
mode = composerMode.value,
|
||||||
attachmentSourcePicker = attachmentSourcePicker,
|
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||||
|
attachmentsState = attachmentsState.value,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.ifMediaPickersEnabled(action: suspend () -> Unit) = launch {
|
private fun CoroutineScope.launchIfMediaPickerEnabled(action: suspend () -> Unit) = launch {
|
||||||
if (featureFlagService.isFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow)) {
|
if (featureFlagService.isFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow)) {
|
||||||
action()
|
action()
|
||||||
}
|
}
|
||||||
|
|
@ -190,44 +184,59 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.sendMedia(
|
private fun CoroutineScope.sendAttachment(
|
||||||
uri: Uri,
|
attachment: Attachment,
|
||||||
mediaType: MediaType,
|
attachmentState: MutableState<AttachmentsState>,
|
||||||
deleteOriginal: Boolean = false
|
|
||||||
) = launch {
|
) = launch {
|
||||||
runCatching {
|
when (attachment) {
|
||||||
val info = handleMediaPreProcessing(uri, mediaType, deleteOriginal).getOrNull() ?: return@runCatching
|
is Attachment.Media -> {
|
||||||
when (info) {
|
sendMedia(
|
||||||
is MediaUploadInfo.Image -> {
|
uri = attachment.localMedia.uri,
|
||||||
room.sendImage(info.file, info.thumbnailInfo.file, info.info)
|
mimeType = attachment.localMedia.mimeType,
|
||||||
}
|
attachmentState = attachmentState
|
||||||
|
)
|
||||||
is MediaUploadInfo.Video -> {
|
}
|
||||||
room.sendVideo(info.file, info.thumbnailInfo.file, info.info)
|
|
||||||
}
|
|
||||||
|
|
||||||
is MediaUploadInfo.AnyFile -> {
|
|
||||||
room.sendFile(info.file, info.info)
|
|
||||||
}
|
|
||||||
else -> error("Unexpected MediaUploadInfo format: $info")
|
|
||||||
}.getOrThrow()
|
|
||||||
}.onFailure {
|
|
||||||
snackbarDispatcher.post(SnackbarMessage(StringR.string.screen_media_upload_preview_error_failed_sending))
|
|
||||||
Timber.e(it, "Couldn't upload media")
|
|
||||||
}.onSuccess {
|
|
||||||
Timber.d("Media uploaded")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handleMediaPreProcessing(
|
@UnstableApi
|
||||||
uri: Uri,
|
private fun handlePickedMedia(
|
||||||
mediaType: MediaType,
|
attachmentsState: MutableState<AttachmentsState>,
|
||||||
deleteOriginal: Boolean,
|
uri: Uri?,
|
||||||
): Result<MediaUploadInfo> {
|
mimeType: String? = null,
|
||||||
val result = mediaPreProcessor.process(uri, mediaType, deleteOriginal = deleteOriginal)
|
compressIfPossible: Boolean = true,
|
||||||
Timber.d("Pre-processed media result: $result")
|
) {
|
||||||
return result.onFailure {
|
if (uri == null) {
|
||||||
snackbarDispatcher.post(SnackbarMessage(StringR.string.screen_media_upload_preview_error_failed_processing))
|
attachmentsState.value = AttachmentsState.None
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
val localMedia = localMediaFactory.createFromUri(uri, mimeType)
|
||||||
|
val mediaAttachment = Attachment.Media(localMedia, compressIfPossible)
|
||||||
|
val isPreviewable = when {
|
||||||
|
MimeTypes.isImage(localMedia.mimeType) -> true
|
||||||
|
MimeTypes.isVideo(localMedia.mimeType) -> true
|
||||||
|
MimeTypes.isAudio(localMedia.mimeType) -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
attachmentsState.value = if (isPreviewable) {
|
||||||
|
AttachmentsState.Previewing(persistentListOf(mediaAttachment))
|
||||||
|
} else {
|
||||||
|
AttachmentsState.Sending(persistentListOf(mediaAttachment))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendMedia(
|
||||||
|
uri: Uri,
|
||||||
|
mimeType: String,
|
||||||
|
attachmentState: MutableState<AttachmentsState>,
|
||||||
|
) {
|
||||||
|
mediaSender.sendMedia(uri, mimeType, compressIfPossible = false)
|
||||||
|
.onSuccess {
|
||||||
|
attachmentState.value = AttachmentsState.None
|
||||||
|
}.onFailure {
|
||||||
|
val snackbarMessage = SnackbarMessage(sendAttachmentError(it))
|
||||||
|
snackbarDispatcher.post(snackbarMessage)
|
||||||
|
attachmentState.value = AttachmentsState.None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -14,24 +14,29 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.textcomposer
|
package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
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(
|
||||||
val text: StableCharSequence?,
|
val text: StableCharSequence?,
|
||||||
val isFullScreen: Boolean,
|
val isFullScreen: Boolean,
|
||||||
val mode: MessageComposerMode,
|
val mode: MessageComposerMode,
|
||||||
val attachmentSourcePicker: AttachmentSourcePicker?,
|
val showAttachmentSourcePicker: Boolean,
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface AttachmentSourcePicker {
|
@Immutable
|
||||||
object AllMedia : AttachmentSourcePicker
|
sealed interface AttachmentsState {
|
||||||
object Camera : AttachmentSourcePicker
|
object None : AttachmentsState
|
||||||
|
data class Previewing(val attachments: ImmutableList<Attachment>) : AttachmentsState
|
||||||
|
data class Sending(val attachments: ImmutableList<Attachment>) : AttachmentsState
|
||||||
}
|
}
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.textcomposer
|
package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.core.data.StableCharSequence
|
import io.element.android.libraries.core.data.StableCharSequence
|
||||||
|
|
@ -31,6 +31,7 @@ fun aMessageComposerState() = MessageComposerState(
|
||||||
text = StableCharSequence(""),
|
text = StableCharSequence(""),
|
||||||
isFullScreen = false,
|
isFullScreen = false,
|
||||||
mode = MessageComposerMode.Normal(content = ""),
|
mode = MessageComposerMode.Normal(content = ""),
|
||||||
attachmentSourcePicker = null,
|
showAttachmentSourcePicker = false,
|
||||||
|
attachmentsState = AttachmentsState.None,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.textcomposer
|
package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -32,8 +32,8 @@ import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
fun aTimelineState() = TimelineState(
|
fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf()) = TimelineState(
|
||||||
timelineItems = persistentListOf(),
|
timelineItems = timelineItems,
|
||||||
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, canBackPaginate = true),
|
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, canBackPaginate = true),
|
||||||
highlightedEventId = null,
|
highlightedEventId = null,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
package io.element.android.features.messages.impl.timeline
|
package io.element.android.features.messages.impl.timeline
|
||||||
|
|
||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.animateContentSize
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
|
@ -33,14 +33,15 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowDownward
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
import androidx.compose.material.icons.filled.Error
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
@ -53,7 +54,6 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.LastBaseline
|
import androidx.compose.ui.layout.LastBaseline
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
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 androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -61,6 +61,7 @@ import androidx.compose.ui.zIndex
|
||||||
import io.element.android.features.messages.impl.R
|
import io.element.android.features.messages.impl.R
|
||||||
import io.element.android.features.messages.impl.timeline.components.MessageEventBubble
|
import io.element.android.features.messages.impl.timeline.components.MessageEventBubble
|
||||||
import io.element.android.features.messages.impl.timeline.components.MessageStateEventContainer
|
import io.element.android.features.messages.impl.timeline.components.MessageStateEventContainer
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.TimelineEventTimestampView
|
||||||
import io.element.android.features.messages.impl.timeline.components.TimelineItemReactionsView
|
import io.element.android.features.messages.impl.timeline.components.TimelineItemReactionsView
|
||||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||||
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
|
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
|
||||||
|
|
@ -70,25 +71,23 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
|
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
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.model.event.TimelineItemVideoContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingModel
|
||||||
import io.element.android.libraries.core.bool.orFalse
|
import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding
|
||||||
import io.element.android.libraries.designsystem.ElementTextStyles
|
|
||||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
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.AvatarData
|
||||||
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.ElementTheme
|
import io.element.android.libraries.designsystem.theme.LocalColors
|
||||||
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
|
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
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.Text
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineView(
|
fun TimelineView(
|
||||||
|
|
@ -234,6 +233,7 @@ fun TimelineItemEventRow(
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
val (parentAlignment, contentAlignment) = if (event.isMine) {
|
val (parentAlignment, contentAlignment) = if (event.isMine) {
|
||||||
Pair(Alignment.CenterEnd, Alignment.End)
|
Pair(Alignment.CenterEnd, Alignment.End)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -257,12 +257,13 @@ fun TimelineItemEventRow(
|
||||||
Modifier.zIndex(1f)
|
Modifier.zIndex(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val bubbleState = BubbleState(
|
||||||
|
groupPosition = event.groupPosition,
|
||||||
|
isMine = event.isMine,
|
||||||
|
isHighlighted = isHighlighted,
|
||||||
|
)
|
||||||
MessageEventBubble(
|
MessageEventBubble(
|
||||||
state = BubbleState(
|
state = bubbleState,
|
||||||
groupPosition = event.groupPosition,
|
|
||||||
isMine = event.isMine,
|
|
||||||
isHighlighted = isHighlighted,
|
|
||||||
),
|
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
|
@ -270,21 +271,12 @@ fun TimelineItemEventRow(
|
||||||
.zIndex(-1f)
|
.zIndex(-1f)
|
||||||
.widthIn(max = 320.dp)
|
.widthIn(max = 320.dp)
|
||||||
) {
|
) {
|
||||||
Column {
|
MessageEventBubbleContent(
|
||||||
val contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 6.dp)
|
event = event,
|
||||||
TimelineItemEventContentView(event.content, interactionSource, onClick, onLongClick, contentModifier)
|
interactionSource = interactionSource,
|
||||||
TimestampView(
|
onMessageClick = onClick,
|
||||||
formattedTime = event.sentTime,
|
onMessageLongClick = onLongClick
|
||||||
hasMessageSendingFailed = event.sendState is EventSendState.SendingFailed,
|
)
|
||||||
isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse(),
|
|
||||||
onClick = {
|
|
||||||
// TODO trigger either resending the message or opening the message edition history. This will be implemented later
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
|
||||||
.align(Alignment.End),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
TimelineItemReactionsView(
|
TimelineItemReactionsView(
|
||||||
reactionsState = event.reactionsState,
|
reactionsState = event.reactionsState,
|
||||||
|
|
@ -329,44 +321,66 @@ fun TimelineItemStateEventRow(
|
||||||
.zIndex(-1f)
|
.zIndex(-1f)
|
||||||
.widthIn(max = 320.dp)
|
.widthIn(max = 320.dp)
|
||||||
) {
|
) {
|
||||||
val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
|
||||||
TimelineItemEventContentView(
|
TimelineItemEventContentView(
|
||||||
content = event.content,
|
content = event.content,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
modifier = contentModifier
|
modifier = Modifier.defaultTimelineContentPadding()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TimestampView(
|
fun MessageEventBubbleContent(
|
||||||
formattedTime: String,
|
event: TimelineItem.Event,
|
||||||
isMessageEdited: Boolean,
|
interactionSource: MutableInteractionSource,
|
||||||
hasMessageSendingFailed: Boolean,
|
onMessageClick: () -> Unit,
|
||||||
onClick: () -> Unit,
|
onMessageLongClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val tint = if (hasMessageSendingFailed) ElementTheme.colors.textActionCritical else null
|
val showTimestampWithOverlay = event.content is TimelineItemImageContent || event.content is TimelineItemVideoContent
|
||||||
Row(modifier = modifier.clickable(onClick = onClick)){
|
|
||||||
if (isMessageEdited) {
|
@Composable
|
||||||
Text(
|
fun ContentView(
|
||||||
stringResource(StringR.string.common_edited_suffix),
|
modifier: Modifier = Modifier
|
||||||
style = ElementTextStyles.Regular.caption2,
|
) {
|
||||||
color = tint ?: MaterialTheme.colorScheme.secondary,
|
TimelineItemEventContentView(
|
||||||
)
|
content = event.content,
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
interactionSource = interactionSource,
|
||||||
}
|
onClick = onMessageClick,
|
||||||
Text(
|
onLongClick = onMessageLongClick,
|
||||||
formattedTime,
|
modifier = modifier,
|
||||||
style = ElementTextStyles.Regular.caption1,
|
|
||||||
color = tint ?: MaterialTheme.colorScheme.secondary,
|
|
||||||
)
|
)
|
||||||
if (hasMessageSendingFailed && tint != null) {
|
}
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
|
||||||
Icon(imageVector = Icons.Default.Error, contentDescription = "Error sending message", tint = tint, modifier = Modifier.size(15.dp, 18.dp))
|
if (showTimestampWithOverlay) {
|
||||||
|
Box(modifier.wrapContentSize()) {
|
||||||
|
ContentView()
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||||
|
.background(LocalColors.current.gray300, RoundedCornerShape(10.0.dp))
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
) {
|
||||||
|
TimelineEventTimestampView(
|
||||||
|
event = event,
|
||||||
|
onClick = onMessageClick,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column {
|
||||||
|
ContentView(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp))
|
||||||
|
TimelineEventTimestampView(
|
||||||
|
event = event,
|
||||||
|
onClick = onMessageClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.End)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -467,8 +481,6 @@ fun TimelineViewDarkPreview(
|
||||||
private fun ContentToPreview(content: TimelineItemEventContent) {
|
private fun ContentToPreview(content: TimelineItemEventContent) {
|
||||||
val timelineItems = aTimelineItemList(content)
|
val timelineItems = aTimelineItemList(content)
|
||||||
TimelineView(
|
TimelineView(
|
||||||
state = aTimelineState().copy(
|
state = aTimelineState(timelineItems)
|
||||||
timelineItems = timelineItems,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
/*
|
||||||
|
* 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.timeline.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Error
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||||
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
|
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||||
|
import io.element.android.libraries.designsystem.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
||||||
|
import io.element.android.libraries.ui.strings.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TimelineEventTimestampView(
|
||||||
|
event: TimelineItem.Event,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val formattedTime = event.sentTime
|
||||||
|
val hasMessageSendingFailed = event.sendState is EventSendState.SendingFailed
|
||||||
|
val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
|
||||||
|
val tint = if (hasMessageSendingFailed) ElementTheme.colors.textActionCritical else null
|
||||||
|
Row(modifier = modifier.clickable(onClick = onClick)) {
|
||||||
|
if (isMessageEdited) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.common_edited_suffix),
|
||||||
|
style = ElementTextStyles.Regular.caption2,
|
||||||
|
color = tint ?: MaterialTheme.colorScheme.secondary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
formattedTime,
|
||||||
|
style = ElementTextStyles.Regular.caption2,
|
||||||
|
color = tint ?: MaterialTheme.colorScheme.secondary,
|
||||||
|
)
|
||||||
|
if (hasMessageSendingFailed && tint != null) {
|
||||||
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
Icon(imageVector = Icons.Default.Error, contentDescription = "Error sending message", tint = tint, modifier = Modifier.size(15.dp, 18.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
/*
|
||||||
|
* 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.timeline.components.blurhash
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.vanniktech.blurhash.BlurHash
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BlurHashAsyncImage(
|
||||||
|
model: Any?,
|
||||||
|
blurHash: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentScale: ContentScale = ContentScale.Fit,
|
||||||
|
contentDescription: String? = null,
|
||||||
|
) {
|
||||||
|
var isLoading by rememberSaveable(model) { mutableStateOf(true) }
|
||||||
|
Box(
|
||||||
|
modifier = modifier,
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
model = model,
|
||||||
|
contentScale = contentScale,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
onSuccess = { isLoading = false }
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isLoading,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
BlurHashImage(
|
||||||
|
blurHash = blurHash,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
contentScale = ContentScale.FillBounds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BlurHashImage(
|
||||||
|
blurHash: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentDescription: String? = null,
|
||||||
|
contentScale: ContentScale = ContentScale.Fit,
|
||||||
|
) {
|
||||||
|
if (blurHash == null) return
|
||||||
|
val bitmapState = remember(blurHash) {
|
||||||
|
mutableStateOf(
|
||||||
|
// Build a small blurhash image so that it's fast
|
||||||
|
BlurHash.decode(blurHash, 10, 10)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DisposableEffect(blurHash) {
|
||||||
|
onDispose {
|
||||||
|
bitmapState.value?.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bitmapState.value?.let { bitmap ->
|
||||||
|
Image(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
bitmap = bitmap.asImageBitmap(),
|
||||||
|
contentScale = contentScale,
|
||||||
|
contentDescription = contentDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
* 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.timeline.components.event
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TimelineItemAspectRatioBox(
|
||||||
|
height: Int?,
|
||||||
|
aspectRatio: Float,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentAlignment: Alignment = Alignment.TopStart,
|
||||||
|
content: @Composable BoxScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
// TODO should probably be moved to an ElementTheme.dimensions
|
||||||
|
val maxHeight = min(300, height ?: 0)
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.heightIn(max = maxHeight.dp)
|
||||||
|
.aspectRatio(aspectRatio, matchHeightConstraintsFirst = true),
|
||||||
|
contentAlignment = contentAlignment,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -17,15 +17,19 @@
|
||||||
package io.element.android.features.messages.impl.timeline.components.event
|
package io.element.android.features.messages.impl.timeline.components.event
|
||||||
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||||
|
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.TimelineItemImageContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
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.model.event.TimelineItemTextBasedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineItemEventContentView(
|
fun TimelineItemEventContentView(
|
||||||
|
|
@ -56,6 +60,14 @@ fun TimelineItemEventContentView(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
is TimelineItemImageContent -> TimelineItemImageView(
|
is TimelineItemImageContent -> TimelineItemImageView(
|
||||||
|
content = content,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
is TimelineItemVideoContent -> TimelineItemVideoView(
|
||||||
|
content = content,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
is TimelineItemFileContent -> TimelineItemFileView(
|
||||||
content = content,
|
content = content,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
/*
|
||||||
|
* 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.timeline.components.event
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Attachment
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TimelineItemFileView(
|
||||||
|
content: TimelineItemFileContent,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.background),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Attachment,
|
||||||
|
contentDescription = "OpenFile"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = content.body,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun TimelineItemFileViewLightPreview(@PreviewParameter(TimelineItemFileContentProvider::class) content: TimelineItemFileContent) =
|
||||||
|
ElementPreviewLight { ContentToPreview(content) }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun TimelineItemFileViewDarkPreview(@PreviewParameter(TimelineItemFileContentProvider::class) content: TimelineItemFileContent) =
|
||||||
|
ElementPreviewDark { ContentToPreview(content) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview(content: TimelineItemFileContent) {
|
||||||
|
TimelineItemFileView(content)
|
||||||
|
}
|
||||||
|
|
@ -16,56 +16,34 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components.event
|
package io.element.android.features.messages.impl.timeline.components.event
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.painter.ColorPainter
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
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 coil.compose.AsyncImage
|
import io.element.android.features.messages.impl.timeline.components.blurhash.BlurHashAsyncImage
|
||||||
import coil.request.ImageRequest
|
|
||||||
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.TimelineItemImageContentProvider
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
|
||||||
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.preview.debugPlaceholderBackground
|
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineItemImageView(
|
fun TimelineItemImageView(
|
||||||
content: TimelineItemImageContent,
|
content: TimelineItemImageContent,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val widthPercent = if (content.aspectRatio > 1f) {
|
TimelineItemAspectRatioBox(
|
||||||
1f
|
height = content.height,
|
||||||
} else {
|
aspectRatio = content.aspectRatio,
|
||||||
0.7f
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth(widthPercent)
|
|
||||||
.aspectRatio(content.aspectRatio),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
) {
|
||||||
val isLoading = rememberSaveable(content.imageMeta) { mutableStateOf(true) }
|
BlurHashAsyncImage(
|
||||||
val context = LocalContext.current
|
model = MediaRequestData(content.mediaSource, MediaRequestData.Kind.Content),
|
||||||
val model = ImageRequest.Builder(context)
|
blurHash = content.blurhash,
|
||||||
.data(content.imageMeta)
|
modifier = Modifier.fillMaxSize(),
|
||||||
.build()
|
contentScale = ContentScale.Fit,
|
||||||
|
|
||||||
AsyncImage(
|
|
||||||
model = model,
|
|
||||||
contentDescription = null,
|
|
||||||
placeholder = debugPlaceholderBackground(ColorPainter(MaterialTheme.colorScheme.surfaceVariant)),
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
onSuccess = { isLoading.value = false },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
/*
|
||||||
|
* 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.timeline.components.event
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.blurhash.BlurHashAsyncImage
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
|
||||||
|
import io.element.android.libraries.designsystem.modifiers.roundedBackground
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TimelineItemVideoView(
|
||||||
|
content: TimelineItemVideoContent,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
TimelineItemAspectRatioBox(
|
||||||
|
height = content.height,
|
||||||
|
aspectRatio = content.aspectRatio,
|
||||||
|
modifier = modifier,
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
BlurHashAsyncImage(
|
||||||
|
model = MediaRequestData(content.thumbnailSource, MediaRequestData.Kind.Content),
|
||||||
|
blurHash = content.blurHash,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.roundedBackground(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
Icons.Default.PlayArrow,
|
||||||
|
contentDescription = "Play",
|
||||||
|
colorFilter = ColorFilter.tint(Color.White),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun TimelineItemVideoViewLightPreview(@PreviewParameter(TimelineItemVideoContentProvider::class) content: TimelineItemVideoContent) =
|
||||||
|
ElementPreviewLight { ContentToPreview(content) }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun TimelineItemVideoViewDarkPreview(@PreviewParameter(TimelineItemVideoContentProvider::class) content: TimelineItemVideoContent) =
|
||||||
|
ElementPreviewDark { ContentToPreview(content) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview(content: TimelineItemVideoContent) {
|
||||||
|
TimelineItemVideoView(content)
|
||||||
|
}
|
||||||
|
|
@ -18,17 +18,20 @@ package io.element.android.features.messages.impl.timeline.factories.event
|
||||||
|
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||||
|
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.TimelineItemImageContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
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.util.toHtmlDocument
|
import io.element.android.features.messages.impl.timeline.util.toHtmlDocument
|
||||||
import io.element.android.libraries.matrix.api.media.MediaResolver
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class TimelineItemContentMessageFactory @Inject constructor() {
|
class TimelineItemContentMessageFactory @Inject constructor() {
|
||||||
|
|
@ -41,23 +44,38 @@ class TimelineItemContentMessageFactory @Inject constructor() {
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
)
|
)
|
||||||
is ImageMessageType -> {
|
is ImageMessageType -> {
|
||||||
val height = messageType.info?.height?.toFloat()
|
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
|
||||||
val width = messageType.info?.width?.toFloat()
|
|
||||||
val aspectRatio = if (height != null && width != null) {
|
|
||||||
width / height
|
|
||||||
} else {
|
|
||||||
0.7f
|
|
||||||
}
|
|
||||||
TimelineItemImageContent(
|
TimelineItemImageContent(
|
||||||
body = messageType.body,
|
body = messageType.body,
|
||||||
imageMeta = MediaResolver.Meta(
|
mediaSource = messageType.source,
|
||||||
url = messageType.url,
|
mimeType = messageType.info?.mimetype,
|
||||||
kind = MediaResolver.Kind.Content
|
|
||||||
),
|
|
||||||
blurhash = messageType.info?.blurhash,
|
blurhash = messageType.info?.blurhash,
|
||||||
|
width = messageType.info?.width?.toInt(),
|
||||||
|
height = messageType.info?.height?.toInt(),
|
||||||
aspectRatio = aspectRatio
|
aspectRatio = aspectRatio
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
is VideoMessageType -> {
|
||||||
|
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
|
||||||
|
TimelineItemVideoContent(
|
||||||
|
body = messageType.body,
|
||||||
|
thumbnailSource = messageType.info?.thumbnailSource,
|
||||||
|
videoSource = messageType.source,
|
||||||
|
mimeType = messageType.info?.mimetype,
|
||||||
|
width = messageType.info?.width?.toInt(),
|
||||||
|
height = messageType.info?.height?.toInt(),
|
||||||
|
duration = messageType.info?.duration ?: 0L,
|
||||||
|
blurHash = messageType.info?.blurhash,
|
||||||
|
aspectRatio = aspectRatio
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is FileMessageType -> TimelineItemFileContent(
|
||||||
|
body = messageType.body,
|
||||||
|
thumbnailSource = messageType.info?.thumbnailSource,
|
||||||
|
fileSource = messageType.source,
|
||||||
|
mimeType = messageType.info?.mimetype,
|
||||||
|
size = messageType.info?.size,
|
||||||
|
)
|
||||||
is NoticeMessageType -> TimelineItemNoticeContent(
|
is NoticeMessageType -> TimelineItemNoticeContent(
|
||||||
body = messageType.body,
|
body = messageType.body,
|
||||||
htmlDocument = messageType.formatted?.toHtmlDocument(),
|
htmlDocument = messageType.formatted?.toHtmlDocument(),
|
||||||
|
|
@ -71,4 +89,12 @@ class TimelineItemContentMessageFactory @Inject constructor() {
|
||||||
else -> TimelineItemUnknownContent
|
else -> TimelineItemUnknownContent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun aspectRatioOf(width: Long?, height: Long?): Float {
|
||||||
|
return if (height != null && width != null) {
|
||||||
|
width.toFloat() / height.toFloat()
|
||||||
|
} else {
|
||||||
|
0.7f
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.groups
|
||||||
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.TimelineItemEmoteContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
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.TimelineItemImageContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
|
||||||
|
|
@ -27,6 +28,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
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.libraries.core.bool.orFalse
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
|
|
@ -66,6 +68,8 @@ class TimelineItemGrouper @Inject constructor() {
|
||||||
is TimelineItemEmoteContent,
|
is TimelineItemEmoteContent,
|
||||||
is TimelineItemNoticeContent,
|
is TimelineItemNoticeContent,
|
||||||
is TimelineItemTextContent,
|
is TimelineItemTextContent,
|
||||||
|
is TimelineItemFileContent,
|
||||||
|
is TimelineItemVideoContent,
|
||||||
TimelineItemUnknownContent -> false
|
TimelineItemUnknownContent -> false
|
||||||
is TimelineItemProfileChangeContent,
|
is TimelineItemProfileChangeContent,
|
||||||
is TimelineItemRoomMembershipContent,
|
is TimelineItemRoomMembershipContent,
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,10 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
||||||
override val values = sequenceOf(
|
override val values = sequenceOf(
|
||||||
aTimelineItemEmoteContent(),
|
aTimelineItemEmoteContent(),
|
||||||
aTimelineItemEncryptedContent(),
|
aTimelineItemEncryptedContent(),
|
||||||
// TODO MessagesTimelineItemImageContent(),
|
aTimelineItemImageContent(),
|
||||||
|
aTimelineItemVideoContent(),
|
||||||
|
aTimelineItemFileContent("A file.pdf"),
|
||||||
|
aTimelineItemFileContent("A bigger file name which doesn't fit.pdf"),
|
||||||
aTimelineItemNoticeContent(),
|
aTimelineItemNoticeContent(),
|
||||||
aTimelineItemRedactedContent(),
|
aTimelineItemRedactedContent(),
|
||||||
aTimelineItemTextContent(),
|
aTimelineItemTextContent(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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.timeline.model.event
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
|
||||||
|
data class TimelineItemFileContent(
|
||||||
|
val body: String,
|
||||||
|
val fileSource: MediaSource,
|
||||||
|
val thumbnailSource: MediaSource?,
|
||||||
|
val size: Long?,
|
||||||
|
val mimeType: String?,
|
||||||
|
) : TimelineItemEventContent {
|
||||||
|
override val type: String = "TimelineItemFileContent"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
* 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.timeline.model.event
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
|
||||||
|
open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineItemFileContent> {
|
||||||
|
override val values: Sequence<TimelineItemFileContent>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aTimelineItemFileContent("A file.pdf"),
|
||||||
|
aTimelineItemFileContent("A bigger name file.pdf"),
|
||||||
|
aTimelineItemFileContent("An even bigger file name which doesn't fit.pdf"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aTimelineItemFileContent(fileName: String) = TimelineItemFileContent(
|
||||||
|
body = fileName,
|
||||||
|
thumbnailSource = MediaSource(url = ""),
|
||||||
|
fileSource = MediaSource(url = ""),
|
||||||
|
mimeType = MimeTypes.OctetStream,
|
||||||
|
size = 100
|
||||||
|
)
|
||||||
|
|
@ -16,13 +16,16 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.model.event
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.media.MediaResolver
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
|
||||||
data class TimelineItemImageContent(
|
data class TimelineItemImageContent(
|
||||||
val body: String,
|
val body: String,
|
||||||
val imageMeta: MediaResolver.Meta,
|
val mediaSource: MediaSource,
|
||||||
|
val mimeType: String?,
|
||||||
val blurhash: String?,
|
val blurhash: String?,
|
||||||
|
val width: Int?,
|
||||||
|
val height: Int?,
|
||||||
val aspectRatio: Float
|
val aspectRatio: Float
|
||||||
) : TimelineItemEventContent{
|
) : TimelineItemEventContent {
|
||||||
override val type: String = "TimelineItemImageContent"
|
override val type: String = "TimelineItemImageContent"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@
|
||||||
package io.element.android.features.messages.impl.timeline.model.event
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.matrix.api.media.MediaResolver
|
import androidx.media3.common.MimeTypes
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
|
||||||
open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineItemImageContent> {
|
open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineItemImageContent> {
|
||||||
override val values: Sequence<TimelineItemImageContent>
|
override val values: Sequence<TimelineItemImageContent>
|
||||||
|
|
@ -30,7 +31,10 @@ open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineI
|
||||||
|
|
||||||
fun aTimelineItemImageContent() = TimelineItemImageContent(
|
fun aTimelineItemImageContent() = TimelineItemImageContent(
|
||||||
body = "a body",
|
body = "a body",
|
||||||
imageMeta = MediaResolver.Meta(url = null, kind = MediaResolver.Kind.Content),
|
mediaSource = MediaSource(""),
|
||||||
blurhash = null,
|
mimeType = MimeTypes.IMAGE_JPEG,
|
||||||
aspectRatio = 0.5f,
|
blurhash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
|
||||||
|
width = null,
|
||||||
|
height = 300,
|
||||||
|
aspectRatio = 0.5f
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 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.timeline.model.event
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
|
||||||
|
data class TimelineItemVideoContent(
|
||||||
|
val body: String,
|
||||||
|
val duration: Long,
|
||||||
|
val videoSource: MediaSource,
|
||||||
|
val thumbnailSource: MediaSource?,
|
||||||
|
val aspectRatio: Float,
|
||||||
|
val blurHash: String?,
|
||||||
|
val height: Int?,
|
||||||
|
val width: Int?,
|
||||||
|
val mimeType: String?,
|
||||||
|
) : TimelineItemEventContent {
|
||||||
|
override val type: String = "TimelineItemImageContent"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
* 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.timeline.model.event
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
|
||||||
|
open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineItemVideoContent> {
|
||||||
|
override val values: Sequence<TimelineItemVideoContent>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aTimelineItemVideoContent(),
|
||||||
|
aTimelineItemVideoContent().copy(aspectRatio = 1.0f),
|
||||||
|
aTimelineItemVideoContent().copy(aspectRatio = 1.5f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aTimelineItemVideoContent() = TimelineItemVideoContent(
|
||||||
|
body = "a video",
|
||||||
|
thumbnailSource = MediaSource(url = ""),
|
||||||
|
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
|
||||||
|
aspectRatio = 0.5f,
|
||||||
|
duration = 100,
|
||||||
|
videoSource = MediaSource(""),
|
||||||
|
height = 300,
|
||||||
|
width = 150,
|
||||||
|
mimeType = null
|
||||||
|
)
|
||||||
|
|
@ -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.timeline.util
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
fun Modifier.defaultTimelineContentPadding() = padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
|
@ -26,9 +26,10 @@ import io.element.android.features.messages.impl.MessagesEvents
|
||||||
import io.element.android.features.messages.impl.MessagesPresenter
|
import io.element.android.features.messages.impl.MessagesPresenter
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||||
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.textcomposer.MessageComposerPresenter
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||||
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
|
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
|
||||||
|
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
|
|
@ -37,6 +38,7 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
|
@ -134,7 +136,8 @@ class MessagesPresenterTest {
|
||||||
room = matrixRoom,
|
room = matrixRoom,
|
||||||
mediaPickerProvider = FakePickerProvider(),
|
mediaPickerProvider = FakePickerProvider(),
|
||||||
featureFlagService = FakeFeatureFlagService(),
|
featureFlagService = FakeFeatureFlagService(),
|
||||||
mediaPreProcessor = FakeMediaPreProcessor(),
|
localMediaFactory = FakeLocalMediaFactory(),
|
||||||
|
mediaSender = MediaSender(FakeMediaPreProcessor(),matrixRoom),
|
||||||
snackbarDispatcher = SnackbarDispatcher(),
|
snackbarDispatcher = SnackbarDispatcher(),
|
||||||
)
|
)
|
||||||
val timelinePresenter = TimelinePresenter(
|
val timelinePresenter = TimelinePresenter(
|
||||||
|
|
@ -153,4 +156,3 @@ class MessagesPresenterTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
/*
|
||||||
|
* 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.attachments
|
||||||
|
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
|
import app.cash.molecule.RecompositionClock
|
||||||
|
import app.cash.molecule.moleculeFlow
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.messages.fixtures.aLocalMedia
|
||||||
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
|
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents
|
||||||
|
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
|
||||||
|
import io.element.android.features.messages.impl.media.local.LocalMedia
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
|
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AttachmentsPreviewPresenterTest {
|
||||||
|
|
||||||
|
private val mediaPreProcessor = FakeMediaPreProcessor()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - send media success scenario`() = runTest {
|
||||||
|
val room = FakeMatrixRoom()
|
||||||
|
val presenter = anAttachmentsPreviewPresenter(room = room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.sendActionState).isEqualTo(Async.Uninitialized)
|
||||||
|
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.sendActionState).isEqualTo(Async.Loading<Unit>())
|
||||||
|
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS)
|
||||||
|
val successState = awaitItem()
|
||||||
|
assertThat(successState.sendActionState).isEqualTo(Async.Success(Unit))
|
||||||
|
assertThat(room.sendMediaCount).isEqualTo(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - send media failure scenario`() = runTest {
|
||||||
|
val room = FakeMatrixRoom()
|
||||||
|
val failure = MediaPreProcessor.Failure(null)
|
||||||
|
room.givenSendMediaResult(Result.failure(failure))
|
||||||
|
val presenter = anAttachmentsPreviewPresenter(room = room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.sendActionState).isEqualTo(Async.Uninitialized)
|
||||||
|
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.sendActionState).isEqualTo(Async.Loading<Unit>())
|
||||||
|
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS)
|
||||||
|
val failureState = awaitItem()
|
||||||
|
assertThat(failureState.sendActionState).isEqualTo(Async.Failure<Unit>(failure))
|
||||||
|
assertThat(room.sendMediaCount).isEqualTo(0)
|
||||||
|
failureState.eventSink(AttachmentsPreviewEvents.ClearSendState)
|
||||||
|
val clearedState = awaitItem()
|
||||||
|
assertThat(clearedState.sendActionState).isEqualTo(Async.Uninitialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun anAttachmentsPreviewPresenter(
|
||||||
|
localMedia: LocalMedia = aLocalMedia(mimeType = MimeTypes.IMAGE_JPEG),
|
||||||
|
room: MatrixRoom = FakeMatrixRoom()
|
||||||
|
): AttachmentsPreviewPresenter {
|
||||||
|
return AttachmentsPreviewPresenter(
|
||||||
|
attachment = Attachment.Media(localMedia, compressIfPossible = false),
|
||||||
|
mediaSender = MediaSender(mediaPreProcessor, room)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
* 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.fixtures
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
|
import io.element.android.features.messages.impl.media.local.LocalMedia
|
||||||
|
import io.mockk.mockk
|
||||||
|
|
||||||
|
fun aLocalMedia(
|
||||||
|
uri: Uri = mockk("localMediaUri"),
|
||||||
|
mimeType: String = MimeTypes.IMAGE_JPEG,
|
||||||
|
name: String = "a media",
|
||||||
|
size: Long = 1000,
|
||||||
|
) = LocalMedia(
|
||||||
|
uri = uri,
|
||||||
|
mimeType = mimeType,
|
||||||
|
name = name,
|
||||||
|
size = size,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media(
|
||||||
|
localMedia = localMedia,
|
||||||
|
compressIfPossible = compressIfPossible,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* 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.media
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import io.element.android.features.messages.fixtures.aLocalMedia
|
||||||
|
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.core.mimetype.MimeTypes
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||||
|
|
||||||
|
class FakeLocalMediaFactory : LocalMediaFactory {
|
||||||
|
|
||||||
|
var fallbackMimeType: String = MimeTypes.OctetStream
|
||||||
|
|
||||||
|
override fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia {
|
||||||
|
return aLocalMedia(mimeType = mimeType ?: fallbackMimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createFromUri(uri: Uri, mimeType: String?): LocalMedia {
|
||||||
|
return aLocalMedia(uri, mimeType ?: fallbackMimeType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
/*
|
||||||
|
* 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.media.viewer
|
||||||
|
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
|
import app.cash.molecule.RecompositionClock
|
||||||
|
import app.cash.molecule.moleculeFlow
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.messages.impl.media.viewer.MediaViewerEvents
|
||||||
|
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
|
||||||
|
import io.element.android.features.messages.impl.media.viewer.MediaViewerPresenter
|
||||||
|
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
|
||||||
|
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
|
||||||
|
import io.element.android.libraries.matrix.test.media.aMediaSource
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
private const val TESTED_MIME_TYPE = MimeTypes.IMAGE_JPEG
|
||||||
|
private const val TESTED_MEDIA_NAME = "MediaName"
|
||||||
|
|
||||||
|
class MediaViewerPresenterTest {
|
||||||
|
|
||||||
|
private val localMediaFactory = FakeLocalMediaFactory()
|
||||||
|
private val mediaLoader = FakeMediaLoader()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - download media success scenario`() = runTest {
|
||||||
|
val presenter = aMediaViewerPresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized)
|
||||||
|
assertThat(initialState.name).isEqualTo(TESTED_MEDIA_NAME)
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
|
||||||
|
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS + 1)
|
||||||
|
val successState = awaitItem()
|
||||||
|
val successData = successState.downloadedMedia.dataOrNull()
|
||||||
|
assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java)
|
||||||
|
assertThat(successData).isNotNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - download media failure then retry with success scenario`() = runTest {
|
||||||
|
val presenter = aMediaViewerPresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
mediaLoader.shouldFail = true
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized)
|
||||||
|
assertThat(initialState.name).isEqualTo(TESTED_MEDIA_NAME)
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
|
||||||
|
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS)
|
||||||
|
val failureState = awaitItem()
|
||||||
|
assertThat(failureState.downloadedMedia).isInstanceOf(Async.Failure::class.java)
|
||||||
|
mediaLoader.shouldFail = false
|
||||||
|
failureState.eventSink(MediaViewerEvents.RetryLoading)
|
||||||
|
//There is one recomposition because of the retry mechanism
|
||||||
|
skipItems(1)
|
||||||
|
val retryLoadingState = awaitItem()
|
||||||
|
assertThat(retryLoadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
|
||||||
|
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS)
|
||||||
|
val successState = awaitItem()
|
||||||
|
val successData = successState.downloadedMedia.dataOrNull()
|
||||||
|
assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java)
|
||||||
|
assertThat(successData).isNotNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun aMediaViewerPresenter(mimeType: String = TESTED_MIME_TYPE): MediaViewerPresenter {
|
||||||
|
return MediaViewerPresenter(
|
||||||
|
inputs = MediaViewerNode.Inputs(
|
||||||
|
name = TESTED_MEDIA_NAME,
|
||||||
|
mediaSource = aMediaSource(),
|
||||||
|
mimeType = mimeType,
|
||||||
|
thumbnailSource = null
|
||||||
|
),
|
||||||
|
localMediaFactory = localMediaFactory,
|
||||||
|
mediaLoader = mediaLoader
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,10 +23,11 @@ import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.ReceiveTurbine
|
import app.cash.turbine.ReceiveTurbine
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker
|
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
||||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerEvents
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||||
|
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||||
import io.element.android.libraries.core.data.StableCharSequence
|
import io.element.android.libraries.core.data.StableCharSequence
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
|
|
@ -46,6 +47,7 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||||
import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo
|
import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo
|
||||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||||
|
|
@ -53,8 +55,6 @@ import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.coroutines.test.runCurrent
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
@ -64,13 +64,12 @@ class MessageComposerPresenterTest {
|
||||||
private val pickerProvider = FakePickerProvider().apply {
|
private val pickerProvider = FakePickerProvider().apply {
|
||||||
givenResult(mockk()) // Uri is not available in JVM, so the only way to have a non-null Uri is using Mockk
|
givenResult(mockk()) // Uri is not available in JVM, so the only way to have a non-null Uri is using Mockk
|
||||||
}
|
}
|
||||||
private val featureFlagService = FakeFeatureFlagService().apply {
|
private val featureFlagService = FakeFeatureFlagService(
|
||||||
runBlocking {
|
mapOf(FeatureFlags.ShowMediaUploadingFlow.key to true)
|
||||||
setFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow, true)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
private val mediaPreProcessor = FakeMediaPreProcessor()
|
private val mediaPreProcessor = FakeMediaPreProcessor()
|
||||||
private val snackbarDispatcher = SnackbarDispatcher()
|
private val snackbarDispatcher = SnackbarDispatcher()
|
||||||
|
private val localMediaFactory = FakeLocalMediaFactory()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
|
|
@ -82,6 +81,8 @@ class MessageComposerPresenterTest {
|
||||||
assertThat(initialState.isFullScreen).isFalse()
|
assertThat(initialState.isFullScreen).isFalse()
|
||||||
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
|
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
|
||||||
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
||||||
|
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
||||||
|
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
|
||||||
assertThat(initialState.isSendButtonVisible).isFalse()
|
assertThat(initialState.isSendButtonVisible).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -259,22 +260,9 @@ class MessageComposerPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.showAttachmentSourcePicker).isEqualTo(false)
|
||||||
initialState.eventSink(MessageComposerEvents.AddAttachment)
|
initialState.eventSink(MessageComposerEvents.AddAttachment)
|
||||||
|
assertThat(awaitItem().showAttachmentSourcePicker).isEqualTo(true)
|
||||||
assertThat(awaitItem().attachmentSourcePicker).isEqualTo(AttachmentSourcePicker.AllMedia)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - Open camera attachments menu`() = runTest {
|
|
||||||
val presenter = createPresenter(this)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromCamera)
|
|
||||||
|
|
||||||
assertThat(awaitItem().attachmentSourcePicker).isEqualTo(AttachmentSourcePicker.Camera)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,7 +277,7 @@ class MessageComposerPresenterTest {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
|
||||||
initialState.eventSink(MessageComposerEvents.DismissAttachmentMenu)
|
initialState.eventSink(MessageComposerEvents.DismissAttachmentMenu)
|
||||||
assertThat(awaitItem().attachmentSourcePicker).isNull()
|
assertThat(awaitItem().showAttachmentSourcePicker).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -308,7 +296,7 @@ class MessageComposerPresenterTest {
|
||||||
mimetype = null,
|
mimetype = null,
|
||||||
size = null,
|
size = null,
|
||||||
thumbnailInfo = null,
|
thumbnailInfo = null,
|
||||||
thumbnailUrl = null,
|
thumbnailSource = null,
|
||||||
blurhash = null,
|
blurhash = null,
|
||||||
),
|
),
|
||||||
thumbnailInfo = ThumbnailProcessingInfo(
|
thumbnailInfo = ThumbnailProcessingInfo(
|
||||||
|
|
@ -329,9 +317,9 @@ class MessageComposerPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
|
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
|
||||||
// Wait for the launched upload coroutine to run
|
val previewingState = awaitItem()
|
||||||
runCurrent()
|
assertThat(previewingState.showAttachmentSourcePicker).isFalse()
|
||||||
assertThat(room.sendMediaCount).isEqualTo(1)
|
assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -351,7 +339,7 @@ class MessageComposerPresenterTest {
|
||||||
duration = null,
|
duration = null,
|
||||||
size = null,
|
size = null,
|
||||||
thumbnailInfo = null,
|
thumbnailInfo = null,
|
||||||
thumbnailUrl = null,
|
thumbnailSource = null,
|
||||||
blurhash = null,
|
blurhash = null,
|
||||||
),
|
),
|
||||||
thumbnailInfo = ThumbnailProcessingInfo(
|
thumbnailInfo = ThumbnailProcessingInfo(
|
||||||
|
|
@ -372,22 +360,9 @@ class MessageComposerPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
|
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
|
||||||
// Wait for the launched upload coroutine to run
|
val previewingState = awaitItem()
|
||||||
runCurrent()
|
assertThat(previewingState.showAttachmentSourcePicker).isFalse()
|
||||||
assertThat(room.sendMediaCount).isEqualTo(1)
|
assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - Pick media from gallery fails if returned mimetype is not video or image`() = runTest {
|
|
||||||
val presenter = createPresenter(this)
|
|
||||||
pickerProvider.givenMimeType(MimeTypes.Audio)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
|
|
||||||
assertThat(awaitError()).isInstanceOf(IllegalStateException::class.java)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -416,8 +391,11 @@ class MessageComposerPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
|
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
|
||||||
// Wait for the launched upload coroutine to run
|
val sendingState = awaitItem()
|
||||||
runCurrent()
|
assertThat(sendingState.showAttachmentSourcePicker).isFalse()
|
||||||
|
assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending::class.java)
|
||||||
|
val sentState = awaitItem()
|
||||||
|
assertThat(sentState.attachmentsState).isEqualTo(AttachmentsState.None)
|
||||||
assertThat(room.sendMediaCount).isEqualTo(1)
|
assertThat(room.sendMediaCount).isEqualTo(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -430,10 +408,11 @@ class MessageComposerPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Photo)
|
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera)
|
||||||
// Wait for the launched upload coroutine to run
|
val previewingState = awaitItem()
|
||||||
runCurrent()
|
assertThat(previewingState.showAttachmentSourcePicker).isFalse()
|
||||||
assertThat(room.sendMediaCount).isEqualTo(1)
|
assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -445,10 +424,10 @@ class MessageComposerPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Video)
|
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera)
|
||||||
// Wait for the launched upload coroutine to run
|
val previewingState = awaitItem()
|
||||||
runCurrent()
|
assertThat(previewingState.showAttachmentSourcePicker).isFalse()
|
||||||
assertThat(room.sendMediaCount).isEqualTo(1)
|
assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -463,10 +442,11 @@ class MessageComposerPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
|
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
|
||||||
|
val sendingState = awaitItem()
|
||||||
|
assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending::class.java)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.None::class.java)
|
||||||
snackbarDispatcher.snackbarMessage.test {
|
snackbarDispatcher.snackbarMessage.test {
|
||||||
// Initial value is always null
|
|
||||||
skipItems(1)
|
|
||||||
// Assert error message received
|
// Assert error message received
|
||||||
assertThat(awaitItem()).isNotNull()
|
assertThat(awaitItem()).isNotNull()
|
||||||
}
|
}
|
||||||
|
|
@ -490,7 +470,13 @@ class MessageComposerPresenterTest {
|
||||||
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
|
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
|
||||||
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
|
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
|
||||||
) = MessageComposerPresenter(
|
) = MessageComposerPresenter(
|
||||||
coroutineScope, room, pickerProvider, featureFlagService, mediaPreProcessor, snackbarDispatcher
|
coroutineScope,
|
||||||
|
room,
|
||||||
|
pickerProvider,
|
||||||
|
featureFlagService,
|
||||||
|
localMediaFactory,
|
||||||
|
MediaSender(mediaPreProcessor, room),
|
||||||
|
snackbarDispatcher
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ recyclerview = "1.3.0"
|
||||||
lifecycle = "2.6.1"
|
lifecycle = "2.6.1"
|
||||||
activity = "1.7.2"
|
activity = "1.7.2"
|
||||||
startup = "1.1.1"
|
startup = "1.1.1"
|
||||||
|
media3 = "1.0.2"
|
||||||
|
|
||||||
# Compose
|
# Compose
|
||||||
compose_bom = "2023.05.01"
|
compose_bom = "2023.05.01"
|
||||||
|
|
@ -41,6 +42,7 @@ appyx = "1.2.0"
|
||||||
dependencycheck = "8.2.1"
|
dependencycheck = "8.2.1"
|
||||||
stem = "2.3.0"
|
stem = "2.3.0"
|
||||||
sqldelight = "1.5.5"
|
sqldelight = "1.5.5"
|
||||||
|
telephoto = "0.3.0"
|
||||||
|
|
||||||
# DI
|
# DI
|
||||||
dagger = "2.46.1"
|
dagger = "2.46.1"
|
||||||
|
|
@ -72,6 +74,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" }
|
||||||
|
|
@ -141,6 +145,7 @@ unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1"
|
||||||
gujun_span = "me.gujun.android:span:1.7"
|
gujun_span = "me.gujun.android:span:1.7"
|
||||||
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
|
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
|
||||||
vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0"
|
vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0"
|
||||||
|
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
|
||||||
|
|
||||||
# Di
|
# Di
|
||||||
inject = "javax.inject:javax.inject:1"
|
inject = "javax.inject:javax.inject:1"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.androidutils.file
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
fun Context.getFileName(uri: Uri): String? = when (uri.scheme) {
|
||||||
|
ContentResolver.SCHEME_CONTENT -> getContentFileName(uri)
|
||||||
|
else -> uri.path?.let(::File)?.name
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Context.getFileSize(uri: Uri): Long {
|
||||||
|
return when (uri.scheme) {
|
||||||
|
ContentResolver.SCHEME_CONTENT -> getContentFileSize(uri)
|
||||||
|
else -> uri.path?.let(::File)?.length()
|
||||||
|
} ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Context.getContentFileSize(uri: Uri): Long? = runCatching {
|
||||||
|
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
cursor.moveToFirst()
|
||||||
|
return@use cursor.getColumnIndexOrThrow(OpenableColumns.SIZE).let(cursor::getLong)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun Context.getContentFileName(uri: Uri): String? = runCatching {
|
||||||
|
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
cursor.moveToFirst()
|
||||||
|
return@use cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME).let(cursor::getString)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
|
@ -18,8 +18,6 @@ package io.element.android.libraries.androidutils.file
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import io.element.android.libraries.core.data.tryOrNull
|
import io.element.android.libraries.core.data.tryOrNull
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -37,7 +35,7 @@ fun File.safeDelete() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null): File = withContext(Dispatchers.IO) {
|
fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null): File {
|
||||||
val suffix = extension?.let { ".$extension" }
|
val suffix = extension?.let { ".$extension" }
|
||||||
File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() }
|
return File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,12 @@ package io.element.android.libraries.androidutils.uri
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
|
||||||
|
const val ASSET_FILE_PATH_ROOT = "android_asset"
|
||||||
const val IGNORED_SCHEMA = "ignored"
|
const val IGNORED_SCHEMA = "ignored"
|
||||||
|
|
||||||
fun Uri.isIgnored() = scheme == IGNORED_SCHEMA
|
fun Uri.isIgnored() = scheme == IGNORED_SCHEMA
|
||||||
|
|
||||||
fun createIgnoredUri(path: String): Uri = Uri.parse("$IGNORED_SCHEMA://$path")
|
fun createIgnoredUri(path: String): Uri = Uri.parse("$IGNORED_SCHEMA://$path")
|
||||||
|
|
||||||
|
val Uri.firstPathSegment: String?
|
||||||
|
get() = pathSegments.firstOrNull()
|
||||||
|
|
|
||||||
|
|
@ -25,3 +25,25 @@ inline fun <R, T : R> Result<T>.mapFailure(transform: (exception: Throwable) ->
|
||||||
else -> Result.failure(transform(exception))
|
else -> Result.failure(transform(exception))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can be used to apply a [transform] that returns a [Result] to a base [Result] and get another [Result].
|
||||||
|
* @return The result of the transform as a [Result].
|
||||||
|
*/
|
||||||
|
inline fun <R, T> Result<T>.flatMap(transform: (T) -> Result<R>): Result<R> {
|
||||||
|
return map(transform).fold(
|
||||||
|
onSuccess = { it },
|
||||||
|
onFailure = { Result.failure(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can be used to apply a [transform] that returns a [Result] to a base [Result] and get another [Result], catching any exception.
|
||||||
|
* @return The result of the transform or a caught exception wrapped in a [Result].
|
||||||
|
*/
|
||||||
|
inline fun <R, T> Result<T>.flatMapCatching(transform: (T) -> Result<R>): Result<R> {
|
||||||
|
return mapCatching(transform).fold(
|
||||||
|
onSuccess = { it },
|
||||||
|
onFailure = { Result.failure(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.core.extensions
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ResultTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFlatMap() {
|
||||||
|
val initial = Result.success("initial")
|
||||||
|
val otherResult = initial.flatMap { Result.success("other") }
|
||||||
|
val errorResult = initial.flatMap { Result.failure<String>(IllegalStateException("error")) }
|
||||||
|
|
||||||
|
assertThat(otherResult.getOrNull()).isEqualTo("other")
|
||||||
|
assertThat(errorResult.exceptionOrNull()?.message).isEqualTo("error")
|
||||||
|
try {
|
||||||
|
initial.flatMap<String, String> { error("caught error") }
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
assertThat(e.message).isEqualTo("caught error")
|
||||||
|
}
|
||||||
|
|
||||||
|
val initialError = Result.failure<String>(IllegalStateException("initial error"))
|
||||||
|
val mapErrorToSuccess = initialError.flatMap { Result.success("other") }
|
||||||
|
val mapErrorToError = initialError.flatMap { Result.failure<String>(IllegalStateException("error")) }
|
||||||
|
val mapErrorAndCatch: Result<String> = initialError.flatMap { error("error") }
|
||||||
|
|
||||||
|
assertThat(mapErrorToSuccess.exceptionOrNull()?.message).isEqualTo("initial error")
|
||||||
|
assertThat(mapErrorToError.exceptionOrNull()?.message).isEqualTo("initial error")
|
||||||
|
assertThat(mapErrorAndCatch.exceptionOrNull()?.message).isEqualTo("initial error")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFlatMapCatching() {
|
||||||
|
val initial = Result.success("initial")
|
||||||
|
val otherResult = initial.flatMapCatching { Result.success("other") }
|
||||||
|
val errorResult = initial.flatMapCatching { Result.failure<String>(IllegalStateException("error")) }
|
||||||
|
val caughtExceptionResult: Result<String> = initial.flatMapCatching { error("caught error") }
|
||||||
|
|
||||||
|
assertThat(otherResult.getOrNull()).isEqualTo("other")
|
||||||
|
assertThat(errorResult.exceptionOrNull()?.message).isEqualTo("error")
|
||||||
|
assertThat(caughtExceptionResult.exceptionOrNull()?.message).isEqualTo("caught error")
|
||||||
|
|
||||||
|
val initialError = Result.failure<String>(IllegalStateException("initial error"))
|
||||||
|
val mapErrorToSuccess = initialError.flatMapCatching { Result.success("other") }
|
||||||
|
val mapErrorToError = initialError.flatMapCatching { Result.failure<String>(IllegalStateException("error")) }
|
||||||
|
val mapErrorAndCatch: Result<String> = initialError.flatMapCatching { error("error") }
|
||||||
|
|
||||||
|
assertThat(mapErrorToSuccess.exceptionOrNull()?.message).isEqualTo("initial error")
|
||||||
|
assertThat(mapErrorToError.exceptionOrNull()?.message).isEqualTo("initial error")
|
||||||
|
assertThat(mapErrorAndCatch.exceptionOrNull()?.message).isEqualTo("initial error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -75,5 +75,7 @@ val LinkColor = Color(0xFF0086E6)
|
||||||
val TextColorCriticalLight = Color(0xFFD51928)
|
val TextColorCriticalLight = Color(0xFFD51928)
|
||||||
val TextColorCriticalDark = Color(0xfffd3e3c)
|
val TextColorCriticalDark = Color(0xfffd3e3c)
|
||||||
|
|
||||||
val Gray_400_Light = Color(0xFFE1E6EC)
|
val Compound_Gray_300_Light = Color(0xFFF0F2F5)
|
||||||
val Gray_400_Dark = Color(0xFF26282D)
|
val Compound_Gray_300_Dark = Color(0xFF1D1F24)
|
||||||
|
val Compound_Gray_400_Light = Color(0xFFE1E6EC)
|
||||||
|
val Compound_Gray_400_Dark = Color(0xFF26282D)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.designsystem.modifiers
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This modifier can be use to provide a nice background for Icon or ProgressIndicator.
|
||||||
|
*/
|
||||||
|
fun Modifier.roundedBackground(
|
||||||
|
size: Dp = 48.dp,
|
||||||
|
color: Color = Color.Black,
|
||||||
|
alpha: Float = 0.5f,
|
||||||
|
) = this
|
||||||
|
.size(size)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(color = color.copy(alpha = alpha))
|
||||||
|
.padding(8.dp)
|
||||||
|
|
@ -23,10 +23,11 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||||
import io.element.android.libraries.designsystem.Azure
|
import io.element.android.libraries.designsystem.Azure
|
||||||
import io.element.android.libraries.designsystem.Black_800
|
import io.element.android.libraries.designsystem.Black_800
|
||||||
import io.element.android.libraries.designsystem.Black_950
|
import io.element.android.libraries.designsystem.Black_950
|
||||||
|
import io.element.android.libraries.designsystem.Compound_Gray_300_Dark
|
||||||
import io.element.android.libraries.designsystem.DarkGrey
|
import io.element.android.libraries.designsystem.DarkGrey
|
||||||
import io.element.android.libraries.designsystem.Gray_300
|
import io.element.android.libraries.designsystem.Gray_300
|
||||||
import io.element.android.libraries.designsystem.Gray_400
|
import io.element.android.libraries.designsystem.Gray_400
|
||||||
import io.element.android.libraries.designsystem.Gray_400_Dark
|
import io.element.android.libraries.designsystem.Compound_Gray_400_Dark
|
||||||
import io.element.android.libraries.designsystem.Gray_450
|
import io.element.android.libraries.designsystem.Gray_450
|
||||||
import io.element.android.libraries.designsystem.SystemGrey5Dark
|
import io.element.android.libraries.designsystem.SystemGrey5Dark
|
||||||
import io.element.android.libraries.designsystem.SystemGrey6Dark
|
import io.element.android.libraries.designsystem.SystemGrey6Dark
|
||||||
|
|
@ -39,7 +40,8 @@ fun elementColorsDark() = ElementColors(
|
||||||
messageHighlightedBackground = Azure,
|
messageHighlightedBackground = Azure,
|
||||||
quaternary = Gray_400,
|
quaternary = Gray_400,
|
||||||
quinary = Gray_450,
|
quinary = Gray_450,
|
||||||
gray400 = Gray_400_Dark,
|
gray300 = Compound_Gray_300_Dark,
|
||||||
|
gray400 = Compound_Gray_400_Dark,
|
||||||
textActionCritical = TextColorCriticalDark,
|
textActionCritical = TextColorCriticalDark,
|
||||||
isLight = false,
|
isLight = false,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ import io.element.android.libraries.designsystem.Gray_100
|
||||||
import io.element.android.libraries.designsystem.Gray_150
|
import io.element.android.libraries.designsystem.Gray_150
|
||||||
import io.element.android.libraries.designsystem.Gray_200
|
import io.element.android.libraries.designsystem.Gray_200
|
||||||
import io.element.android.libraries.designsystem.Gray_25
|
import io.element.android.libraries.designsystem.Gray_25
|
||||||
import io.element.android.libraries.designsystem.Gray_400_Light
|
import io.element.android.libraries.designsystem.Compound_Gray_300_Light
|
||||||
|
import io.element.android.libraries.designsystem.Compound_Gray_400_Light
|
||||||
import io.element.android.libraries.designsystem.Gray_50
|
import io.element.android.libraries.designsystem.Gray_50
|
||||||
import io.element.android.libraries.designsystem.SystemGrey5Light
|
import io.element.android.libraries.designsystem.SystemGrey5Light
|
||||||
import io.element.android.libraries.designsystem.SystemGrey6Light
|
import io.element.android.libraries.designsystem.SystemGrey6Light
|
||||||
|
|
@ -39,7 +40,8 @@ fun elementColorsLight() = ElementColors(
|
||||||
messageHighlightedBackground = Azure,
|
messageHighlightedBackground = Azure,
|
||||||
quaternary = Gray_100,
|
quaternary = Gray_100,
|
||||||
quinary = Gray_50,
|
quinary = Gray_50,
|
||||||
gray400 = Gray_400_Light,
|
gray300 = Compound_Gray_300_Light,
|
||||||
|
gray400 = Compound_Gray_400_Light,
|
||||||
textActionCritical = TextColorCriticalLight,
|
textActionCritical = TextColorCriticalLight,
|
||||||
isLight = true,
|
isLight = true,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class ElementColors(
|
||||||
messageHighlightedBackground: Color,
|
messageHighlightedBackground: Color,
|
||||||
quaternary: Color,
|
quaternary: Color,
|
||||||
quinary: Color,
|
quinary: Color,
|
||||||
|
gray300: Color,
|
||||||
gray400: Color,
|
gray400: Color,
|
||||||
textActionCritical: Color,
|
textActionCritical: Color,
|
||||||
isLight: Boolean
|
isLight: Boolean
|
||||||
|
|
@ -46,6 +47,9 @@ class ElementColors(
|
||||||
var quinary by mutableStateOf(quinary)
|
var quinary by mutableStateOf(quinary)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
var gray300 by mutableStateOf(gray400)
|
||||||
|
private set
|
||||||
|
|
||||||
var gray400 by mutableStateOf(gray400)
|
var gray400 by mutableStateOf(gray400)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
|
@ -61,6 +65,7 @@ class ElementColors(
|
||||||
messageHighlightedBackground: Color = this.messageHighlightedBackground,
|
messageHighlightedBackground: Color = this.messageHighlightedBackground,
|
||||||
quaternary: Color = this.quaternary,
|
quaternary: Color = this.quaternary,
|
||||||
quinary: Color = this.quinary,
|
quinary: Color = this.quinary,
|
||||||
|
gray300: Color = this.gray300,
|
||||||
gray400: Color = this.gray400,
|
gray400: Color = this.gray400,
|
||||||
textActionCritical: Color = this.textActionCritical,
|
textActionCritical: Color = this.textActionCritical,
|
||||||
isLight: Boolean = this.isLight,
|
isLight: Boolean = this.isLight,
|
||||||
|
|
@ -70,6 +75,7 @@ class ElementColors(
|
||||||
messageHighlightedBackground = messageHighlightedBackground,
|
messageHighlightedBackground = messageHighlightedBackground,
|
||||||
quaternary = quaternary,
|
quaternary = quaternary,
|
||||||
quinary = quinary,
|
quinary = quinary,
|
||||||
|
gray300 = gray300,
|
||||||
gray400 = gray400,
|
gray400 = gray400,
|
||||||
textActionCritical = textActionCritical,
|
textActionCritical = textActionCritical,
|
||||||
isLight = isLight,
|
isLight = isLight,
|
||||||
|
|
@ -81,6 +87,7 @@ class ElementColors(
|
||||||
messageHighlightedBackground = other.messageHighlightedBackground
|
messageHighlightedBackground = other.messageHighlightedBackground
|
||||||
quaternary = other.quaternary
|
quaternary = other.quaternary
|
||||||
quinary = other.quinary
|
quinary = other.quinary
|
||||||
|
gray300 = other.gray300
|
||||||
gray400 = other.gray400
|
gray400 = other.gray400
|
||||||
textActionCritical = other.textActionCritical
|
textActionCritical = other.textActionCritical
|
||||||
isLight = other.isLight
|
isLight = other.isLight
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,14 @@ import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.ReadOnlyComposable
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import com.google.accompanist.systemuicontroller.SystemUiController
|
||||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -55,7 +57,6 @@ fun ElementTheme(
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val systemUiController = rememberSystemUiController()
|
val systemUiController = rememberSystemUiController()
|
||||||
val useDarkIcons = !darkTheme
|
|
||||||
val currentColor = remember(darkTheme) {
|
val currentColor = remember(darkTheme) {
|
||||||
colors.copy()
|
colors.copy()
|
||||||
}.apply { updateColorsFrom(colors) }
|
}.apply { updateColorsFrom(colors) }
|
||||||
|
|
@ -68,13 +69,7 @@ fun ElementTheme(
|
||||||
else -> materialLightColors
|
else -> materialLightColors
|
||||||
}
|
}
|
||||||
SideEffect {
|
SideEffect {
|
||||||
systemUiController.setStatusBarColor(
|
systemUiController.applyTheme(colorScheme = colorScheme, darkTheme = darkTheme)
|
||||||
color = colorScheme.background
|
|
||||||
)
|
|
||||||
systemUiController.setSystemBarsColor(
|
|
||||||
color = Color.Transparent,
|
|
||||||
darkIcons = useDarkIcons
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalColors provides currentColor,
|
LocalColors provides currentColor,
|
||||||
|
|
@ -86,3 +81,36 @@ fun ElementTheme(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can be used to force a composable in dark theme.
|
||||||
|
* It will automatically change the system ui colors back to normal when leaving the composition.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ForcedDarkElementTheme(
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val systemUiController = rememberSystemUiController()
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val wasDarkTheme = !ElementTheme.colors.isLight
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
systemUiController.applyTheme(colorScheme, wasDarkTheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ElementTheme(darkTheme = true, content = content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SystemUiController.applyTheme(
|
||||||
|
colorScheme: ColorScheme,
|
||||||
|
darkTheme: Boolean,
|
||||||
|
) {
|
||||||
|
val useDarkIcons = !darkTheme
|
||||||
|
setStatusBarColor(
|
||||||
|
color = colorScheme.background
|
||||||
|
)
|
||||||
|
setSystemBarsColor(
|
||||||
|
color = Color.Transparent,
|
||||||
|
darkIcons = useDarkIcons
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
package io.element.android.libraries.designsystem.theme.components
|
package io.element.android.libraries.designsystem.theme.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.DividerDefaults
|
import androidx.compose.material3.DividerDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,6 @@ import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import androidx.compose.ui.text.AnnotatedString
|
||||||
import com.google.common.truth.Truth
|
import com.google.common.truth.Truth
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
||||||
|
|
@ -156,10 +157,10 @@ class DefaultRoomLastMessageFormatterTests {
|
||||||
|
|
||||||
val sharedContentMessagesTypes = arrayOf(
|
val sharedContentMessagesTypes = arrayOf(
|
||||||
TextMessageType(body, null),
|
TextMessageType(body, null),
|
||||||
VideoMessageType(body, "url", null),
|
VideoMessageType(body, MediaSource("url"), null),
|
||||||
AudioMessageType(body, "url", null),
|
AudioMessageType(body, MediaSource("url"), null),
|
||||||
ImageMessageType(body, "url", null),
|
ImageMessageType(body, MediaSource("url"), null),
|
||||||
FileMessageType(body, "url", null),
|
FileMessageType(body, MediaSource("url"), null),
|
||||||
NoticeMessageType(body, null),
|
NoticeMessageType(body, null),
|
||||||
EmoteMessageType(body, null),
|
EmoteMessageType(body, null),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ 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.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
|
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
|
||||||
import io.element.android.libraries.matrix.api.media.MediaResolver
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.api.notification.NotificationService
|
import io.element.android.libraries.matrix.api.notification.NotificationService
|
||||||
import io.element.android.libraries.matrix.api.pusher.PushersService
|
import io.element.android.libraries.matrix.api.pusher.PushersService
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
|
@ -35,6 +35,7 @@ interface MatrixClient : Closeable {
|
||||||
val sessionId: SessionId
|
val sessionId: SessionId
|
||||||
val roomSummaryDataSource: RoomSummaryDataSource
|
val roomSummaryDataSource: RoomSummaryDataSource
|
||||||
val invitesDataSource: RoomSummaryDataSource
|
val invitesDataSource: RoomSummaryDataSource
|
||||||
|
val mediaLoader: MatrixMediaLoader
|
||||||
fun getRoom(roomId: RoomId): MatrixRoom?
|
fun getRoom(roomId: RoomId): MatrixRoom?
|
||||||
fun findDM(userId: UserId): MatrixRoom?
|
fun findDM(userId: UserId): MatrixRoom?
|
||||||
suspend fun ignoreUser(userId: UserId): Result<Unit>
|
suspend fun ignoreUser(userId: UserId): Result<Unit>
|
||||||
|
|
@ -45,24 +46,13 @@ interface MatrixClient : Closeable {
|
||||||
suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults>
|
suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults>
|
||||||
fun startSync()
|
fun startSync()
|
||||||
fun stopSync()
|
fun stopSync()
|
||||||
fun mediaResolver(): MediaResolver
|
|
||||||
fun sessionVerificationService(): SessionVerificationService
|
fun sessionVerificationService(): SessionVerificationService
|
||||||
fun pushersService(): PushersService
|
fun pushersService(): PushersService
|
||||||
fun notificationService(): NotificationService
|
fun notificationService(): NotificationService
|
||||||
suspend fun logout()
|
suspend fun logout()
|
||||||
suspend fun loadUserDisplayName(): Result<String>
|
suspend fun loadUserDisplayName(): Result<String>
|
||||||
suspend fun loadUserAvatarURLString(): Result<String?>
|
suspend fun loadUserAvatarURLString(): Result<String?>
|
||||||
suspend fun loadMediaContent(url: String): Result<ByteArray>
|
|
||||||
suspend fun loadMediaThumbnail(
|
|
||||||
url: String,
|
|
||||||
width: Long,
|
|
||||||
height: Long
|
|
||||||
): Result<ByteArray>
|
|
||||||
|
|
||||||
suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String>
|
suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String>
|
||||||
|
|
||||||
fun onSlidingSyncUpdate()
|
fun onSlidingSyncUpdate()
|
||||||
|
|
||||||
fun roomMembershipObserver(): RoomMembershipObserver
|
fun roomMembershipObserver(): RoomMembershipObserver
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,5 +20,5 @@ data class FileInfo(
|
||||||
val mimetype: String?,
|
val mimetype: String?,
|
||||||
val size: Long?,
|
val size: Long?,
|
||||||
val thumbnailInfo: ThumbnailInfo?,
|
val thumbnailInfo: ThumbnailInfo?,
|
||||||
val thumbnailUrl: String?
|
val thumbnailSource: MediaSource?
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,6 @@ data class ImageInfo(
|
||||||
val mimetype: String?,
|
val mimetype: String?,
|
||||||
val size: Long?,
|
val size: Long?,
|
||||||
val thumbnailInfo: ThumbnailInfo?,
|
val thumbnailInfo: ThumbnailInfo?,
|
||||||
val thumbnailUrl: String?,
|
val thumbnailSource: MediaSource?,
|
||||||
val blurhash: String?
|
val blurhash: String?
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.api.media
|
||||||
|
|
||||||
|
interface MatrixMediaLoader {
|
||||||
|
/**
|
||||||
|
* @param source to fetch the content for.
|
||||||
|
* @return a [Result] of ByteArray. It contains the binary data for the media.
|
||||||
|
*/
|
||||||
|
suspend fun loadMediaContent(source: MediaSource): Result<ByteArray>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param source to fetch the data for.
|
||||||
|
* @param width: the desired width for rescaling the media as thumbnail
|
||||||
|
* @param height: the desired height for rescaling the media as thumbnail
|
||||||
|
* @return a [Result] of ByteArray. It contains the binary data for the media.
|
||||||
|
*/
|
||||||
|
suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result<ByteArray>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param source to fetch the data for.
|
||||||
|
* @param mimeType: optional mime type
|
||||||
|
* @return a [Result] of [MediaFile]
|
||||||
|
*/
|
||||||
|
suspend fun downloadMediaFile(source: MediaSource, mimeType: String?): Result<MediaFile>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.api.media
|
||||||
|
|
||||||
|
import java.io.Closeable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper around a media file on the disk.
|
||||||
|
* When closed the file will be removed from the disk.
|
||||||
|
*/
|
||||||
|
interface MediaFile : Closeable {
|
||||||
|
fun path(): String
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.api.media
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class MediaSource(
|
||||||
|
/**
|
||||||
|
* Url of the media.
|
||||||
|
*/
|
||||||
|
val url: String,
|
||||||
|
/**
|
||||||
|
* This is used to hold data for encrypted media.
|
||||||
|
*/
|
||||||
|
val json: String? = null,
|
||||||
|
) : Parcelable
|
||||||
|
|
@ -23,6 +23,6 @@ data class VideoInfo(
|
||||||
val mimetype: String?,
|
val mimetype: String?,
|
||||||
val size: Long?,
|
val size: Long?,
|
||||||
val thumbnailInfo: ThumbnailInfo?,
|
val thumbnailInfo: ThumbnailInfo?,
|
||||||
val thumbnailUrl: String?,
|
val thumbnailSource: MediaSource?,
|
||||||
val blurhash: String?
|
val blurhash: String?
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||||
|
|
||||||
sealed interface EventContent
|
sealed interface EventContent
|
||||||
|
|
@ -106,25 +107,25 @@ data class EmoteMessageType(
|
||||||
|
|
||||||
data class ImageMessageType(
|
data class ImageMessageType(
|
||||||
val body: String,
|
val body: String,
|
||||||
val url: String,
|
val source: MediaSource,
|
||||||
val info: ImageInfo?
|
val info: ImageInfo?
|
||||||
) : MessageType
|
) : MessageType
|
||||||
|
|
||||||
data class AudioMessageType(
|
data class AudioMessageType(
|
||||||
var body: String,
|
val body: String,
|
||||||
var url: String,
|
val source: MediaSource,
|
||||||
var info: AudioInfo?
|
val info: AudioInfo?
|
||||||
) : MessageType
|
) : MessageType
|
||||||
|
|
||||||
data class VideoMessageType(
|
data class VideoMessageType(
|
||||||
val body: String,
|
val body: String,
|
||||||
val url: String,
|
val source: MediaSource,
|
||||||
val info: VideoInfo?
|
val info: VideoInfo?
|
||||||
) : MessageType
|
) : MessageType
|
||||||
|
|
||||||
data class FileMessageType(
|
data class FileMessageType(
|
||||||
val body: String,
|
val body: String,
|
||||||
val url: String,
|
val source: MediaSource,
|
||||||
val info: FileInfo?
|
val info: FileInfo?
|
||||||
) : MessageType
|
) : MessageType
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
|
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
|
||||||
import io.element.android.libraries.matrix.api.createroom.RoomPreset
|
import io.element.android.libraries.matrix.api.createroom.RoomPreset
|
||||||
import io.element.android.libraries.matrix.api.createroom.RoomVisibility
|
import io.element.android.libraries.matrix.api.createroom.RoomVisibility
|
||||||
import io.element.android.libraries.matrix.api.media.MediaResolver
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.api.notification.NotificationService
|
import io.element.android.libraries.matrix.api.notification.NotificationService
|
||||||
import io.element.android.libraries.matrix.api.pusher.PushersService
|
import io.element.android.libraries.matrix.api.pusher.PushersService
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
|
@ -34,7 +34,7 @@ import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
import io.element.android.libraries.matrix.impl.media.RustMediaResolver
|
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
|
||||||
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
|
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
|
||||||
import io.element.android.libraries.matrix.impl.pushers.RustPushersService
|
import io.element.android.libraries.matrix.impl.pushers.RustPushersService
|
||||||
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
|
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
|
||||||
|
|
@ -63,7 +63,6 @@ import org.matrix.rustcomponents.sdk.SlidingSyncListBuilder
|
||||||
import org.matrix.rustcomponents.sdk.SlidingSyncListOnceBuilt
|
import org.matrix.rustcomponents.sdk.SlidingSyncListOnceBuilt
|
||||||
import org.matrix.rustcomponents.sdk.SlidingSyncRequestListFilters
|
import org.matrix.rustcomponents.sdk.SlidingSyncRequestListFilters
|
||||||
import org.matrix.rustcomponents.sdk.TaskHandle
|
import org.matrix.rustcomponents.sdk.TaskHandle
|
||||||
import org.matrix.rustcomponents.sdk.mediaSourceFromUrl
|
|
||||||
import org.matrix.rustcomponents.sdk.use
|
import org.matrix.rustcomponents.sdk.use
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
@ -186,9 +185,12 @@ class RustMatrixClient constructor(
|
||||||
override val invitesDataSource: RoomSummaryDataSource
|
override val invitesDataSource: RoomSummaryDataSource
|
||||||
get() = rustInvitesDataSource
|
get() = rustInvitesDataSource
|
||||||
|
|
||||||
|
private val rustMediaLoader = RustMediaLoader(dispatchers, client)
|
||||||
|
override val mediaLoader: MatrixMediaLoader
|
||||||
|
get() = rustMediaLoader
|
||||||
|
|
||||||
private var slidingSyncObserverToken: TaskHandle? = null
|
private var slidingSyncObserverToken: TaskHandle? = null
|
||||||
|
|
||||||
private val mediaResolver = RustMediaResolver(this)
|
|
||||||
private val isSyncing = AtomicBoolean(false)
|
private val isSyncing = AtomicBoolean(false)
|
||||||
|
|
||||||
private val roomMembershipObserver = RoomMembershipObserver()
|
private val roomMembershipObserver = RoomMembershipObserver()
|
||||||
|
|
@ -288,8 +290,6 @@ class RustMatrixClient constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun mediaResolver(): MediaResolver = mediaResolver
|
|
||||||
|
|
||||||
override fun sessionVerificationService(): SessionVerificationService = verificationService
|
override fun sessionVerificationService(): SessionVerificationService = verificationService
|
||||||
|
|
||||||
override fun pushersService(): PushersService = pushersService
|
override fun pushersService(): PushersService = pushersService
|
||||||
|
|
@ -347,34 +347,6 @@ class RustMatrixClient constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalUnsignedTypes::class)
|
|
||||||
override suspend fun loadMediaContent(url: String): Result<ByteArray> =
|
|
||||||
withContext(dispatchers.io) {
|
|
||||||
runCatching {
|
|
||||||
mediaSourceFromUrl(url).use { source ->
|
|
||||||
client.getMediaContent(source).toUByteArray().toByteArray()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalUnsignedTypes::class)
|
|
||||||
override suspend fun loadMediaThumbnail(
|
|
||||||
url: String,
|
|
||||||
width: Long,
|
|
||||||
height: Long
|
|
||||||
): Result<ByteArray> =
|
|
||||||
withContext(dispatchers.io) {
|
|
||||||
runCatching {
|
|
||||||
mediaSourceFromUrl(url).use { mediaSource ->
|
|
||||||
client.getMediaThumbnail(
|
|
||||||
mediaSource = mediaSource,
|
|
||||||
width = width.toULong(),
|
|
||||||
height = height.toULong()
|
|
||||||
).toUByteArray().toByteArray()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalUnsignedTypes::class)
|
@OptIn(ExperimentalUnsignedTypes::class)
|
||||||
override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String> = withContext(dispatchers.io) {
|
override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String> = withContext(dispatchers.io) {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
|
|
||||||
|
|
@ -22,16 +22,16 @@ import dagger.Provides
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
import io.element.android.libraries.di.SingleIn
|
import io.element.android.libraries.di.SingleIn
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@ContributesTo(SessionScope::class)
|
@ContributesTo(SessionScope::class)
|
||||||
object SessionMatrixModule {
|
object SessionMatrixModule {
|
||||||
@Provides
|
@Provides
|
||||||
@SingleIn(SessionScope::class)
|
@SingleIn(SessionScope::class)
|
||||||
fun providesRustSessionVerificationService(matrixClient: MatrixClient): SessionVerificationService {
|
fun providesSessionVerificationService(matrixClient: MatrixClient): SessionVerificationService {
|
||||||
return matrixClient.sessionVerificationService()
|
return matrixClient.sessionVerificationService()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,4 +40,10 @@ object SessionMatrixModule {
|
||||||
fun provideRoomMembershipObserver(matrixClient: MatrixClient): RoomMembershipObserver {
|
fun provideRoomMembershipObserver(matrixClient: MatrixClient): RoomMembershipObserver {
|
||||||
return matrixClient.roomMembershipObserver()
|
return matrixClient.roomMembershipObserver()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@SingleIn(SessionScope::class)
|
||||||
|
fun provideMediaLoader(matrixClient: MatrixClient): MatrixMediaLoader {
|
||||||
|
return matrixClient.mediaLoader
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,15 +17,13 @@
|
||||||
package io.element.android.libraries.matrix.impl.media
|
package io.element.android.libraries.matrix.impl.media
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||||
import io.element.android.libraries.matrix.api.media.ThumbnailInfo
|
|
||||||
import org.matrix.rustcomponents.sdk.FileInfo as RustFileInfo
|
import org.matrix.rustcomponents.sdk.FileInfo as RustFileInfo
|
||||||
import org.matrix.rustcomponents.sdk.ThumbnailInfo as RustThumbnailInfo
|
|
||||||
|
|
||||||
fun RustFileInfo.map(): FileInfo = FileInfo(
|
fun RustFileInfo.map(): FileInfo = FileInfo(
|
||||||
mimetype = mimetype,
|
mimetype = mimetype,
|
||||||
size = size?.toLong(),
|
size = size?.toLong(),
|
||||||
thumbnailInfo = thumbnailInfo?.map(),
|
thumbnailInfo = thumbnailInfo?.map(),
|
||||||
thumbnailUrl = thumbnailSource?.useUrl()
|
thumbnailSource = thumbnailSource?.map()
|
||||||
)
|
)
|
||||||
|
|
||||||
fun FileInfo.map(): RustFileInfo = RustFileInfo(
|
fun FileInfo.map(): RustFileInfo = RustFileInfo(
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ fun RustImageInfo.map(): ImageInfo = ImageInfo(
|
||||||
mimetype = mimetype,
|
mimetype = mimetype,
|
||||||
size = size?.toLong(),
|
size = size?.toLong(),
|
||||||
thumbnailInfo = thumbnailInfo?.map(),
|
thumbnailInfo = thumbnailInfo?.map(),
|
||||||
thumbnailUrl = thumbnailSource?.useUrl(),
|
thumbnailSource = thumbnailSource?.map(),
|
||||||
blurhash = blurhash
|
blurhash = blurhash
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,10 @@
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.impl.media
|
package io.element.android.libraries.matrix.impl.media
|
||||||
|
|
||||||
import org.matrix.rustcomponents.sdk.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import org.matrix.rustcomponents.sdk.use
|
import org.matrix.rustcomponents.sdk.use
|
||||||
|
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
|
||||||
|
|
||||||
fun MediaSource.useUrl(): String = use { it.url() }
|
fun RustMediaSource.map(): MediaSource = use {
|
||||||
|
MediaSource(it.url(), it.toJson())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2022 New Vector Ltd
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -16,21 +16,16 @@
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.impl.media
|
package io.element.android.libraries.matrix.impl.media
|
||||||
|
|
||||||
interface MediaResolver {
|
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||||
|
import org.matrix.rustcomponents.sdk.MediaFileHandle
|
||||||
|
|
||||||
sealed interface Kind {
|
class RustMediaFile(private val inner: MediaFileHandle) : MediaFile {
|
||||||
data class Thumbnail(val width: Int, val height: Int) : Kind {
|
|
||||||
constructor(size: Int) : this(size, size)
|
|
||||||
}
|
|
||||||
|
|
||||||
object Content : Kind
|
override fun path(): String {
|
||||||
|
return inner.path()
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Meta(
|
override fun close() {
|
||||||
val url: String?,
|
inner.close()
|
||||||
val kind: Kind
|
}
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun resolve(url: String?, kind: Kind): ByteArray?
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.impl.media
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.matrix.rustcomponents.sdk.Client
|
||||||
|
import org.matrix.rustcomponents.sdk.mediaSourceFromUrl
|
||||||
|
import org.matrix.rustcomponents.sdk.use
|
||||||
|
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
|
||||||
|
|
||||||
|
class RustMediaLoader(
|
||||||
|
private val dispatchers: CoroutineDispatchers,
|
||||||
|
private val innerClient: Client
|
||||||
|
) : MatrixMediaLoader {
|
||||||
|
|
||||||
|
@OptIn(ExperimentalUnsignedTypes::class)
|
||||||
|
override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> =
|
||||||
|
withContext(dispatchers.io) {
|
||||||
|
runCatching {
|
||||||
|
source.toRustMediaSource().use { source ->
|
||||||
|
innerClient.getMediaContent(source).toUByteArray().toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalUnsignedTypes::class)
|
||||||
|
override suspend fun loadMediaThumbnail(
|
||||||
|
source: MediaSource,
|
||||||
|
width: Long,
|
||||||
|
height: Long
|
||||||
|
): Result<ByteArray> =
|
||||||
|
withContext(dispatchers.io) {
|
||||||
|
runCatching {
|
||||||
|
source.toRustMediaSource().use { mediaSource ->
|
||||||
|
innerClient.getMediaThumbnail(
|
||||||
|
mediaSource = mediaSource,
|
||||||
|
width = width.toULong(),
|
||||||
|
height = height.toULong()
|
||||||
|
).toUByteArray().toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?): Result<MediaFile> =
|
||||||
|
withContext(dispatchers.io) {
|
||||||
|
runCatching {
|
||||||
|
source.toRustMediaSource().use { mediaSource ->
|
||||||
|
val mediaFile = innerClient.getMediaFile(
|
||||||
|
mediaSource = mediaSource,
|
||||||
|
body = null,
|
||||||
|
mimeType = mimeType ?: "application/octet-stream"
|
||||||
|
)
|
||||||
|
RustMediaFile(mediaFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MediaSource.toRustMediaSource(): RustMediaSource {
|
||||||
|
val json = this.json
|
||||||
|
return if (json != null) {
|
||||||
|
RustMediaSource.fromJson(json)
|
||||||
|
} else {
|
||||||
|
mediaSourceFromUrl(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
/*
|
|
||||||
* 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.libraries.matrix.impl.media
|
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
|
||||||
import io.element.android.libraries.matrix.api.media.MediaResolver
|
|
||||||
|
|
||||||
internal class RustMediaResolver(private val client: MatrixClient) : MediaResolver {
|
|
||||||
|
|
||||||
override suspend fun resolve(url: String?, kind: MediaResolver.Kind): ByteArray? {
|
|
||||||
if (url.isNullOrEmpty()) return null
|
|
||||||
return when (kind) {
|
|
||||||
is MediaResolver.Kind.Content -> client.loadMediaContent(url)
|
|
||||||
is MediaResolver.Kind.Thumbnail -> client.loadMediaThumbnail(
|
|
||||||
url,
|
|
||||||
kind.width.toLong(),
|
|
||||||
kind.height.toLong()
|
|
||||||
)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -26,7 +26,7 @@ fun RustVideoInfo.map(): VideoInfo = VideoInfo(
|
||||||
mimetype = mimetype,
|
mimetype = mimetype,
|
||||||
size = size?.toLong(),
|
size = size?.toLong(),
|
||||||
thumbnailInfo = thumbnailInfo?.map(),
|
thumbnailInfo = thumbnailInfo?.map(),
|
||||||
thumbnailUrl = thumbnailSource?.useUrl(),
|
thumbnailSource = thumbnailSource?.map(),
|
||||||
blurhash = blurhash
|
blurhash = blurhash
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -222,28 +222,27 @@ class RustMatrixRoom(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> {
|
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||||
return runCatching {
|
runCatching {
|
||||||
innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map())
|
innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> {
|
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||||
return runCatching {
|
runCatching {
|
||||||
innerRoom.sendVideo(file.path, thumbnailFile.path, videoInfo.map())
|
innerRoom.sendVideo(file.path, thumbnailFile.path, videoInfo.map())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result<Unit> {
|
override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||||
return runCatching {
|
runCatching {
|
||||||
innerRoom.sendAudio(file.path, audioInfo.map())
|
innerRoom.sendAudio(file.path, audioInfo.map())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendFile(file: File, fileInfo: FileInfo): Result<Unit> {
|
override suspend fun sendFile(file: File, fileInfo: FileInfo): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||||
return runCatching {
|
runCatching {
|
||||||
innerRoom.sendFile(file.path, fileInfo.map())
|
innerRoom.sendFile(file.path, fileInfo.map())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||||
import io.element.android.libraries.matrix.impl.media.map
|
import io.element.android.libraries.matrix.impl.media.map
|
||||||
import io.element.android.libraries.matrix.impl.media.useUrl
|
|
||||||
import org.matrix.rustcomponents.sdk.Message
|
import org.matrix.rustcomponents.sdk.Message
|
||||||
import org.matrix.rustcomponents.sdk.MessageType
|
import org.matrix.rustcomponents.sdk.MessageType
|
||||||
import org.matrix.rustcomponents.sdk.use
|
import org.matrix.rustcomponents.sdk.use
|
||||||
|
|
@ -42,13 +41,13 @@ class EventMessageMapper {
|
||||||
val type = it.msgtype().use { type ->
|
val type = it.msgtype().use { type ->
|
||||||
when (type) {
|
when (type) {
|
||||||
is MessageType.Audio -> {
|
is MessageType.Audio -> {
|
||||||
AudioMessageType(type.content.body, type.content.source.useUrl(), type.content.info?.map())
|
AudioMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
|
||||||
}
|
}
|
||||||
is MessageType.File -> {
|
is MessageType.File -> {
|
||||||
FileMessageType(type.content.body, type.content.source.useUrl(), type.content.info?.map())
|
FileMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
|
||||||
}
|
}
|
||||||
is MessageType.Image -> {
|
is MessageType.Image -> {
|
||||||
ImageMessageType(type.content.body, type.content.source.useUrl(), type.content.info?.map())
|
ImageMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
|
||||||
}
|
}
|
||||||
is MessageType.Notice -> {
|
is MessageType.Notice -> {
|
||||||
NoticeMessageType(type.content.body, type.content.formatted?.map())
|
NoticeMessageType(type.content.body, type.content.formatted?.map())
|
||||||
|
|
@ -60,7 +59,7 @@ class EventMessageMapper {
|
||||||
EmoteMessageType(type.content.body, type.content.formatted?.map())
|
EmoteMessageType(type.content.body, type.content.formatted?.map())
|
||||||
}
|
}
|
||||||
is MessageType.Video -> {
|
is MessageType.Video -> {
|
||||||
VideoMessageType(type.content.body, type.content.source.useUrl(), type.content.info?.map())
|
VideoMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
|
||||||
}
|
}
|
||||||
null -> {
|
null -> {
|
||||||
UnknownMessageType
|
UnknownMessageType
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ class TimelineEventContentMapper(private val eventMessageMapper: EventMessageMap
|
||||||
StickerContent(
|
StickerContent(
|
||||||
body = kind.body,
|
body = kind.body,
|
||||||
info = kind.info.map(),
|
info = kind.info.map(),
|
||||||
url = kind.url
|
url = kind.url,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is TimelineItemContentKind.UnableToDecrypt -> {
|
is TimelineItemContentKind.UnableToDecrypt -> {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ 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.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
|
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
|
||||||
import io.element.android.libraries.matrix.api.media.MediaResolver
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.api.notification.NotificationService
|
import io.element.android.libraries.matrix.api.notification.NotificationService
|
||||||
import io.element.android.libraries.matrix.api.pusher.PushersService
|
import io.element.android.libraries.matrix.api.pusher.PushersService
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
|
@ -30,7 +30,7 @@ import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
import io.element.android.libraries.matrix.test.media.FakeMediaResolver
|
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
|
||||||
import io.element.android.libraries.matrix.test.notification.FakeNotificationService
|
import io.element.android.libraries.matrix.test.notification.FakeNotificationService
|
||||||
import io.element.android.libraries.matrix.test.pushers.FakePushersService
|
import io.element.android.libraries.matrix.test.pushers.FakePushersService
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
|
|
@ -44,6 +44,7 @@ class FakeMatrixClient(
|
||||||
private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL),
|
private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL),
|
||||||
override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
|
override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
|
||||||
override val invitesDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
|
override val invitesDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
|
||||||
|
override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(),
|
||||||
private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
|
private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
|
||||||
private val pushersService: FakePushersService = FakePushersService(),
|
private val pushersService: FakePushersService = FakePushersService(),
|
||||||
private val notificationService: FakeNotificationService = FakeNotificationService(),
|
private val notificationService: FakeNotificationService = FakeNotificationService(),
|
||||||
|
|
@ -100,10 +101,6 @@ class FakeMatrixClient(
|
||||||
|
|
||||||
override fun stopSync() = Unit
|
override fun stopSync() = Unit
|
||||||
|
|
||||||
override fun mediaResolver(): MediaResolver {
|
|
||||||
return FakeMediaResolver()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun logout() {
|
override suspend fun logout() {
|
||||||
delay(100)
|
delay(100)
|
||||||
logoutFailure?.let { throw it }
|
logoutFailure?.let { throw it }
|
||||||
|
|
@ -119,14 +116,6 @@ class FakeMatrixClient(
|
||||||
return userAvatarURLString
|
return userAvatarURLString
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun loadMediaContent(url: String): Result<ByteArray> {
|
|
||||||
return Result.success(ByteArray(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun loadMediaThumbnail(url: String, width: Long, height: Long): Result<ByteArray> {
|
|
||||||
return Result.success(ByteArray(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String> {
|
override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String> {
|
||||||
return uploadMediaResult
|
return uploadMediaResult
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,9 @@ val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, true, null)
|
||||||
const val AN_AVATAR_URL = "mxc://data"
|
const val AN_AVATAR_URL = "mxc://data"
|
||||||
|
|
||||||
const val A_FAILURE_REASON = "There has been a failure"
|
const val A_FAILURE_REASON = "There has been a failure"
|
||||||
|
|
||||||
|
const val FAKE_DELAY_IN_MS = 100L
|
||||||
|
|
||||||
val A_THROWABLE = Throwable(A_FAILURE_REASON)
|
val A_THROWABLE = Throwable(A_FAILURE_REASON)
|
||||||
val AN_EXCEPTION = Exception(A_FAILURE_REASON)
|
val AN_EXCEPTION = Exception(A_FAILURE_REASON)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,12 @@
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.test.media
|
package io.element.android.libraries.matrix.test.media
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.media.MediaResolver
|
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||||
|
|
||||||
class FakeMediaResolver : MediaResolver {
|
class FakeMediaFile(private val path: String) : MediaFile {
|
||||||
override suspend fun resolve(url: String?, kind: MediaResolver.Kind): ByteArray? {
|
override fun path(): String {
|
||||||
return null
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun close() = Unit
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.test.media
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
class FakeMediaLoader : MatrixMediaLoader {
|
||||||
|
|
||||||
|
var shouldFail = false
|
||||||
|
|
||||||
|
override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> {
|
||||||
|
delay(FAKE_DELAY_IN_MS)
|
||||||
|
return if (shouldFail) {
|
||||||
|
Result.failure(RuntimeException())
|
||||||
|
} else {
|
||||||
|
return Result.success(ByteArray(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result<ByteArray> {
|
||||||
|
delay(FAKE_DELAY_IN_MS)
|
||||||
|
return if (shouldFail) {
|
||||||
|
Result.failure(RuntimeException())
|
||||||
|
} else {
|
||||||
|
return Result.success(ByteArray(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?): Result<MediaFile> {
|
||||||
|
delay(FAKE_DELAY_IN_MS)
|
||||||
|
return if (shouldFail) {
|
||||||
|
Result.failure(RuntimeException())
|
||||||
|
} else {
|
||||||
|
return Result.success(FakeMediaFile(""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.test.media
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
|
||||||
|
fun aMediaSource(url: String = "") = MediaSource(
|
||||||
|
url = url,
|
||||||
|
json = null
|
||||||
|
)
|
||||||
|
|
@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
|
||||||
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
|
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
@ -99,7 +100,7 @@ class FakeMatrixRoom(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendMessage(message: String): Result<Unit> {
|
override suspend fun sendMessage(message: String): Result<Unit> {
|
||||||
delay(100)
|
delay(FAKE_DELAY_IN_MS)
|
||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,7 +109,7 @@ class FakeMatrixRoom(
|
||||||
|
|
||||||
override suspend fun editMessage(originalEventId: EventId, message: String): Result<Unit> {
|
override suspend fun editMessage(originalEventId: EventId, message: String): Result<Unit> {
|
||||||
editMessageParameter = message
|
editMessageParameter = message
|
||||||
delay(100)
|
delay(FAKE_DELAY_IN_MS)
|
||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,7 +118,7 @@ class FakeMatrixRoom(
|
||||||
|
|
||||||
override suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> {
|
override suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> {
|
||||||
replyMessageParameter = message
|
replyMessageParameter = message
|
||||||
delay(100)
|
delay(FAKE_DELAY_IN_MS)
|
||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,7 +127,7 @@ class FakeMatrixRoom(
|
||||||
|
|
||||||
override suspend fun redactEvent(eventId: EventId, reason: String?): Result<Unit> {
|
override suspend fun redactEvent(eventId: EventId, reason: String?): Result<Unit> {
|
||||||
redactEventEventIdParam = eventId
|
redactEventEventIdParam = eventId
|
||||||
delay(100)
|
delay(FAKE_DELAY_IN_MS)
|
||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,13 +151,20 @@ class FakeMatrixRoom(
|
||||||
return canInviteResult
|
return canInviteResult
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ }
|
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = fakeSendMedia()
|
||||||
|
|
||||||
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ }
|
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> = fakeSendMedia()
|
||||||
|
|
||||||
override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ }
|
override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result<Unit> = fakeSendMedia()
|
||||||
|
|
||||||
override suspend fun sendFile(file: File, fileInfo: FileInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ }
|
override suspend fun sendFile(file: File, fileInfo: FileInfo): Result<Unit> = fakeSendMedia()
|
||||||
|
|
||||||
|
private suspend fun fakeSendMedia(): Result<Unit> {
|
||||||
|
delay(FAKE_DELAY_IN_MS)
|
||||||
|
return sendMediaResult.onSuccess {
|
||||||
|
sendMediaCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun close() = Unit
|
override fun close() = Unit
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue