Merge branch 'develop' into feature/fga/update-rust-sdk-0.1.16
This commit is contained in:
@@ -17,6 +17,9 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- To be able to install APK from the application -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name=".ElementXApplication"
|
||||
android:allowBackup="true"
|
||||
|
||||
@@ -246,6 +246,7 @@ koverMerged {
|
||||
excludes += "io.element.android.libraries.matrix.api.room.MatrixRoomMembersState*"
|
||||
excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*"
|
||||
excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState"
|
||||
excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
|
||||
}
|
||||
bound {
|
||||
minValue = 90
|
||||
|
||||
@@ -32,6 +32,7 @@ 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.local.MediaInfo
|
||||
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.TimelineItemFileContent
|
||||
@@ -64,10 +65,9 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||
|
||||
@Parcelize
|
||||
data class MediaViewer(
|
||||
val title: String,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val mimeType: String?,
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
@@ -100,10 +100,9 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||
}
|
||||
is NavTarget.MediaViewer -> {
|
||||
val inputs = MediaViewerNode.Inputs(
|
||||
name = navTarget.title,
|
||||
mediaInfo = navTarget.mediaInfo,
|
||||
mediaSource = navTarget.mediaSource,
|
||||
thumbnailSource = navTarget.thumbnailSource,
|
||||
mimeType = navTarget.mimeType,
|
||||
)
|
||||
createNode<MediaViewerNode>(buildContext, listOf(inputs))
|
||||
}
|
||||
@@ -118,30 +117,39 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||
when (event.content) {
|
||||
is TimelineItemImageContent -> {
|
||||
val navTarget = NavTarget.MediaViewer(
|
||||
title = event.content.body,
|
||||
mediaInfo = MediaInfo(
|
||||
name = event.content.body,
|
||||
mimeType = event.content.mimeType,
|
||||
formattedFileSize = event.content.formattedFileSize
|
||||
),
|
||||
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,
|
||||
mediaInfo = MediaInfo(
|
||||
name = event.content.body,
|
||||
mimeType = event.content.mimeType,
|
||||
formattedFileSize = event.content.formattedFileSize
|
||||
),
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = event.content.thumbnailSource,
|
||||
mimeType = event.content.mimeType,
|
||||
)
|
||||
backstack.push(navTarget)
|
||||
}
|
||||
is TimelineItemFileContent -> {
|
||||
val mediaSource = event.content.fileSource
|
||||
val navTarget = NavTarget.MediaViewer(
|
||||
title = event.content.body,
|
||||
mediaInfo = MediaInfo(
|
||||
name = event.content.body,
|
||||
mimeType = event.content.mimeType,
|
||||
formattedFileSize = event.content.formattedFileSize
|
||||
),
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = event.content.thumbnailSource,
|
||||
mimeType = event.content.mimeType,
|
||||
)
|
||||
backstack.push(navTarget)
|
||||
}
|
||||
|
||||
@@ -33,16 +33,26 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
||||
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.model.TimelineItem
|
||||
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.TimelineItemRedactedContent
|
||||
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.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
@@ -55,6 +65,7 @@ class MessagesPresenter @Inject constructor(
|
||||
private val actionListPresenter: ActionListPresenter,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
private val messageSummaryFormatter: MessageSummaryFormatter,
|
||||
) : Presenter<MessagesState> {
|
||||
|
||||
@Composable
|
||||
@@ -145,7 +156,38 @@ class MessagesPresenter @Inject constructor(
|
||||
|
||||
private fun handleActionReply(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {
|
||||
if (targetEvent.eventId == null) return
|
||||
val composerMode = MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.eventId, "")
|
||||
val textContent = messageSummaryFormatter.format(targetEvent)
|
||||
val attachmentThumbnailInfo = when (targetEvent.content) {
|
||||
is TimelineItemImageContent -> AttachmentThumbnailInfo(
|
||||
mediaSource = targetEvent.content.mediaSource,
|
||||
textContent = targetEvent.content.body,
|
||||
type = AttachmentThumbnailType.Image,
|
||||
blurHash = targetEvent.content.blurhash,
|
||||
)
|
||||
is TimelineItemVideoContent -> AttachmentThumbnailInfo(
|
||||
mediaSource = targetEvent.content.thumbnailSource,
|
||||
textContent = targetEvent.content.body,
|
||||
type = AttachmentThumbnailType.Video,
|
||||
blurHash = targetEvent.content.blurHash,
|
||||
)
|
||||
is TimelineItemFileContent -> AttachmentThumbnailInfo(
|
||||
mediaSource = targetEvent.content.thumbnailSource,
|
||||
textContent = targetEvent.content.body,
|
||||
type = AttachmentThumbnailType.File,
|
||||
blurHash = null,
|
||||
)
|
||||
is TimelineItemTextBasedContent,
|
||||
is TimelineItemRedactedContent,
|
||||
is TimelineItemStateContent,
|
||||
is TimelineItemEncryptedContent,
|
||||
is TimelineItemUnknownContent -> null
|
||||
}
|
||||
val composerMode = MessageComposerMode.Reply(
|
||||
senderName = targetEvent.safeSenderName,
|
||||
eventId = targetEvent.eventId,
|
||||
attachmentThumbnailInfo = attachmentThumbnailInfo,
|
||||
defaultContent = textContent,
|
||||
)
|
||||
composerState.eventSink(
|
||||
MessageComposerEvents.SetMode(composerMode)
|
||||
)
|
||||
|
||||
@@ -76,6 +76,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -94,23 +95,11 @@ fun MessagesView(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val snackbarMessageText = state.snackbarMessage?.let { stringResource(it.messageResId) }
|
||||
if (snackbarMessageText != null) {
|
||||
SideEffect {
|
||||
coroutineScope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = snackbarMessageText,
|
||||
duration = state.snackbarMessage.duration
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||
|
||||
// This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
|
||||
val localView = LocalView.current
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
package io.element.android.features.messages.impl.actionlist
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -36,19 +35,16 @@ import androidx.compose.material.ListItem
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AddReaction
|
||||
import androidx.compose.material.icons.outlined.Attachment
|
||||
import androidx.compose.material.icons.outlined.VideoCameraBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -56,7 +52,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.timeline.components.blurhash.BlurHashAsyncImage
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||
@@ -67,6 +62,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatterImpl
|
||||
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
@@ -75,8 +71,9 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.Divider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -189,70 +186,56 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
|
||||
Text(body, style = contentStyle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val formatter = remember(context) { MessageSummaryFormatterImpl(context) }
|
||||
val textContent = remember(event.content) { formatter.format(event) }
|
||||
|
||||
when (event.content) {
|
||||
is TimelineItemTextBasedContent -> content = { ContentForBody(event.content.body) }
|
||||
is TimelineItemStateContent -> content = { ContentForBody(event.content.body) }
|
||||
is TimelineItemProfileChangeContent -> content = { ContentForBody(event.content.body) }
|
||||
is TimelineItemEncryptedContent -> content = { ContentForBody(stringResource(StringR.string.common_unable_to_decrypt)) }
|
||||
is TimelineItemRedactedContent -> content = { ContentForBody(stringResource(StringR.string.common_message_removed)) }
|
||||
is TimelineItemUnknownContent -> content = { ContentForBody(stringResource(StringR.string.common_unsupported_event)) }
|
||||
is TimelineItemTextBasedContent,
|
||||
is TimelineItemStateContent,
|
||||
is TimelineItemProfileChangeContent,
|
||||
is TimelineItemEncryptedContent,
|
||||
is TimelineItemRedactedContent,
|
||||
is TimelineItemUnknownContent -> content = { ContentForBody(textContent) }
|
||||
is TimelineItemImageContent -> {
|
||||
icon = {
|
||||
val mediaRequestData = MediaRequestData(
|
||||
source = event.content.mediaSource,
|
||||
kind = MediaRequestData.Kind.Thumbnail(32),
|
||||
)
|
||||
BlurHashAsyncImage(
|
||||
model = mediaRequestData,
|
||||
blurHash = event.content.blurhash,
|
||||
contentDescription = stringResource(StringR.string.common_image),
|
||||
contentScale = ContentScale.Crop,
|
||||
AttachmentThumbnail(
|
||||
modifier = imageModifier,
|
||||
info = AttachmentThumbnailInfo(
|
||||
mediaSource = event.content.mediaSource,
|
||||
textContent = textContent,
|
||||
type = AttachmentThumbnailType.File,
|
||||
blurHash = event.content.blurhash,
|
||||
)
|
||||
)
|
||||
}
|
||||
content = { ContentForBody(event.content.body) }
|
||||
}
|
||||
is TimelineItemVideoContent -> {
|
||||
icon = {
|
||||
val thumbnailSource = event.content.thumbnailSource
|
||||
if (thumbnailSource != null) {
|
||||
val mediaRequestData = MediaRequestData(
|
||||
source = event.content.thumbnailSource,
|
||||
kind = MediaRequestData.Kind.Thumbnail(32),
|
||||
)
|
||||
BlurHashAsyncImage(
|
||||
model = mediaRequestData,
|
||||
AttachmentThumbnail(
|
||||
modifier = imageModifier,
|
||||
info = AttachmentThumbnailInfo(
|
||||
mediaSource = event.content.thumbnailSource,
|
||||
textContent = textContent,
|
||||
type = AttachmentThumbnailType.Video,
|
||||
blurHash = event.content.blurHash,
|
||||
contentDescription = stringResource(StringR.string.common_video),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = imageModifier,
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = imageModifier.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.VideoCameraBack,
|
||||
contentDescription = stringResource(StringR.string.common_video),
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
content = { ContentForBody(event.content.body) }
|
||||
}
|
||||
is TimelineItemFileContent -> {
|
||||
icon = {
|
||||
Box(
|
||||
modifier = imageModifier.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Attachment,
|
||||
contentDescription = stringResource(StringR.string.common_file),
|
||||
modifier = Modifier.rotate(-45f)
|
||||
AttachmentThumbnail(
|
||||
modifier = imageModifier,
|
||||
info = AttachmentThumbnailInfo(
|
||||
mediaSource = null,
|
||||
textContent = textContent,
|
||||
type = AttachmentThumbnailType.File,
|
||||
blurHash = null
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
content = { ContentForBody(event.content.body) }
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||
sendActionState: MutableState<Async<Unit>>,
|
||||
) {
|
||||
suspend {
|
||||
mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.mimeType, mediaAttachment.compressIfPossible)
|
||||
mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.info.mimeType, mediaAttachment.compressIfPossible)
|
||||
}.executeResult(sendActionState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,21 +20,27 @@ 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.features.messages.impl.media.local.MediaInfo
|
||||
import io.element.android.features.messages.impl.media.local.aFileInfo
|
||||
import io.element.android.features.messages.impl.media.local.aVideoInfo
|
||||
import io.element.android.features.messages.impl.media.local.anImageInfo
|
||||
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(mediaInfo = aFileInfo()),
|
||||
anAttachmentsPreviewState(sendActionState = Async.Loading()),
|
||||
anAttachmentsPreviewState(sendActionState = Async.Failure(RuntimeException())),
|
||||
)
|
||||
}
|
||||
|
||||
fun anAttachmentsPreviewState(sendActionState: Async<Unit> = Async.Uninitialized) = AttachmentsPreviewState(
|
||||
fun anAttachmentsPreviewState(
|
||||
mediaInfo: MediaInfo = anImageInfo(),
|
||||
sendActionState: Async<Unit> = Async.Uninitialized) = AttachmentsPreviewState(
|
||||
attachment = Attachment.Media(
|
||||
localMedia = LocalMedia("path".toUri(), MimeTypes.Jpeg, "an image", 1000L),
|
||||
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
|
||||
compressIfPossible = true
|
||||
),
|
||||
sendActionState = sendActionState,
|
||||
|
||||
@@ -121,7 +121,8 @@ private fun AttachmentPreviewContent(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when (attachment) {
|
||||
is Attachment.Media -> LocalMediaView(
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.media.helper
|
||||
|
||||
import android.webkit.MimeTypeMap
|
||||
|
||||
fun formatFileExtensionAndSize(name: String, size: String?): String {
|
||||
val fileExtension = name.substringAfterLast('.', "")
|
||||
// Makes sure the extension is known by the system, otherwise default to binary extension.
|
||||
val safeExtension = if (MimeTypeMap.getSingleton().hasExtension(fileExtension)) {
|
||||
fileExtension.uppercase()
|
||||
} else {
|
||||
"BIN"
|
||||
}
|
||||
return buildString {
|
||||
append(safeExtension)
|
||||
if (size != null) {
|
||||
append(' ')
|
||||
append("($size)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* 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.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toFile
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class AndroidLocalMediaActions @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
private val buildMeta: BuildMeta,
|
||||
) : LocalMediaActions {
|
||||
|
||||
private var activityContext: Context? = null
|
||||
|
||||
@Composable
|
||||
override fun Configure() {
|
||||
val context = LocalContext.current
|
||||
return DisposableEffect(Unit) {
|
||||
activityContext = context
|
||||
onDispose {
|
||||
activityContext = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||
require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE)
|
||||
runCatching {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
saveOnDiskUsingMediaStore(localMedia)
|
||||
} else {
|
||||
saveOnDiskUsingExternalStorageApi(localMedia)
|
||||
}
|
||||
}.onSuccess {
|
||||
Timber.v("Save on disk succeed")
|
||||
}.onFailure {
|
||||
Timber.e(it, "Save on disk failed")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun share(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||
require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE)
|
||||
runCatching {
|
||||
val shareableUri = localMedia.toShareableUri()
|
||||
val shareMediaIntent = Intent(Intent.ACTION_SEND)
|
||||
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.putExtra(Intent.EXTRA_STREAM, shareableUri)
|
||||
.setTypeAndNormalize(localMedia.info.mimeType)
|
||||
withContext(coroutineDispatchers.main) {
|
||||
val intent = Intent.createChooser(shareMediaIntent, null)
|
||||
activityContext!!.startActivity(intent)
|
||||
}
|
||||
}.onSuccess {
|
||||
Timber.v("Share media succeed")
|
||||
}.onFailure {
|
||||
Timber.e(it, "Share media failed")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun open(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||
require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE)
|
||||
runCatching {
|
||||
val openMediaIntent = Intent(Intent.ACTION_VIEW)
|
||||
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.setDataAndType(localMedia.toShareableUri(), localMedia.info.mimeType)
|
||||
withContext(coroutineDispatchers.main) {
|
||||
activityContext!!.startActivity(openMediaIntent)
|
||||
}
|
||||
}.onSuccess {
|
||||
Timber.v("Open media succeed")
|
||||
}.onFailure {
|
||||
Timber.e(it, "Open media failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun LocalMedia.toShareableUri(): Uri {
|
||||
val mediaAsFile = this.toFile()
|
||||
val authority = "${buildMeta.applicationId}.fileprovider"
|
||||
return FileProvider.getUriForFile(context, authority, mediaAsFile).normalizeScheme()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun saveOnDiskUsingMediaStore(localMedia: LocalMedia) {
|
||||
val contentValues = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, localMedia.info.name)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, localMedia.info.mimeType)
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||
}
|
||||
val resolver = context.contentResolver
|
||||
val outputUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
|
||||
if (outputUri != null) {
|
||||
localMedia.openStream()?.use { input ->
|
||||
resolver.openOutputStream(outputUri).use { output ->
|
||||
input.copyTo(output!!, DEFAULT_BUFFER_SIZE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveOnDiskUsingExternalStorageApi(localMedia: LocalMedia) {
|
||||
val target = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
localMedia.info.name
|
||||
)
|
||||
localMedia.openStream()?.use { input ->
|
||||
FileOutputStream(target).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LocalMedia.openStream(): InputStream? {
|
||||
return context.contentResolver.openInputStream(uri)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to extract a file from the uri.
|
||||
*/
|
||||
private fun LocalMedia.toFile(): File {
|
||||
return uri.toFile()
|
||||
}
|
||||
}
|
||||
@@ -20,33 +20,50 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter
|
||||
import io.element.android.libraries.androidutils.file.getFileName
|
||||
import io.element.android.libraries.androidutils.file.getFileSize
|
||||
import io.element.android.libraries.androidutils.file.getMimeType
|
||||
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 io.element.android.libraries.matrix.api.media.toFile
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class AndroidLocalMediaFactory @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
@ApplicationContext private val context: Context,
|
||||
private val fileSizeFormatter: FileSizeFormatter,
|
||||
) : LocalMediaFactory {
|
||||
|
||||
override fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia {
|
||||
val uri = mediaFile.path().toUri()
|
||||
return createFromUri(uri, mimeType)
|
||||
override fun createFromMediaFile(mediaFile: MediaFile, mediaInfo: MediaInfo): LocalMedia {
|
||||
val uri = mediaFile.toFile().toUri()
|
||||
return createFromUri(
|
||||
uri = uri,
|
||||
mimeType = mediaInfo.mimeType,
|
||||
name = mediaInfo.name,
|
||||
formattedFileSize = mediaInfo.formattedFileSize
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
override fun createFromUri(
|
||||
uri: Uri,
|
||||
mimeType: String?,
|
||||
name: String?,
|
||||
formattedFileSize: String?
|
||||
): LocalMedia {
|
||||
val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream
|
||||
val fileName = name ?: context.getFileName(uri) ?: ""
|
||||
val fileSize = formattedFileSize ?: fileSizeFormatter.format(context.getFileSize(uri))
|
||||
return LocalMedia(
|
||||
uri = uri,
|
||||
mimeType = resolvedMimeType,
|
||||
name = fileName,
|
||||
size = fileSize
|
||||
info = MediaInfo(
|
||||
mimeType = resolvedMimeType,
|
||||
name = fileName,
|
||||
formattedFileSize = fileSize
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,22 +19,11 @@ 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
|
||||
}
|
||||
}
|
||||
val info: MediaInfo,
|
||||
) : Parcelable
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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 androidx.compose.runtime.Composable
|
||||
|
||||
interface LocalMediaActions {
|
||||
|
||||
@Composable
|
||||
fun Configure()
|
||||
|
||||
/**
|
||||
* Will save the current media to the Downloads directory.
|
||||
* The [LocalMedia.uri] needs to have a file scheme.
|
||||
*/
|
||||
suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit>
|
||||
|
||||
/**
|
||||
* Will try to find a suitable application to share the media with.
|
||||
* The [LocalMedia.uri] needs to have a file scheme.
|
||||
*/
|
||||
suspend fun share(localMedia: LocalMedia): Result<Unit>
|
||||
|
||||
/**
|
||||
* Will try to find a suitable application to open the media with.
|
||||
* The [LocalMedia.uri] needs to have a file scheme.
|
||||
*/
|
||||
suspend fun open(localMedia: LocalMedia): Result<Unit>
|
||||
}
|
||||
|
||||
@@ -22,13 +22,21 @@ import io.element.android.libraries.matrix.api.media.MediaFile
|
||||
interface LocalMediaFactory {
|
||||
|
||||
/**
|
||||
* This method will create a [LocalMedia] with the given [MediaFile] and [mimeType].
|
||||
* This method will create a [LocalMedia] with the given [MediaFile] and [MediaInfo].
|
||||
*/
|
||||
fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia
|
||||
fun createFromMediaFile(
|
||||
mediaFile: MediaFile,
|
||||
mediaInfo: MediaInfo,
|
||||
): 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.
|
||||
* This method will create a [LocalMedia] with the given mimeType, name and formattedFileSize
|
||||
* If any of those params are null, it'll try to read them from the content.
|
||||
*/
|
||||
fun createFromUri(uri: Uri, mimeType: String?): LocalMedia
|
||||
fun createFromUri(
|
||||
uri: Uri,
|
||||
mimeType: String?,
|
||||
name: String?,
|
||||
formattedFileSize: String?
|
||||
): LocalMedia
|
||||
}
|
||||
|
||||
@@ -17,18 +17,37 @@
|
||||
package io.element.android.features.messages.impl.media.local
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.Uri
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.outlined.Attachment
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.media3.common.MediaItem
|
||||
@@ -36,6 +55,7 @@ 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.helper.formatFileExtensionAndSize
|
||||
import io.element.android.features.messages.impl.media.local.exoplayer.ExoPlayerWrapper
|
||||
import io.element.android.features.messages.impl.media.local.pdf.PdfViewer
|
||||
import io.element.android.features.messages.impl.media.local.pdf.rememberPdfViewerState
|
||||
@@ -43,6 +63,9 @@ 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.R
|
||||
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.designsystem.utils.OnLifecycleEvent
|
||||
import me.saket.telephoto.zoomable.ZoomSpec
|
||||
import me.saket.telephoto.zoomable.ZoomableState
|
||||
@@ -55,39 +78,45 @@ import me.saket.telephoto.zoomable.rememberZoomableState
|
||||
fun LocalMediaView(
|
||||
localMedia: LocalMedia?,
|
||||
modifier: Modifier = Modifier,
|
||||
mimeType: String? = localMedia?.mimeType,
|
||||
onReady: () -> Unit = {},
|
||||
localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
|
||||
mediaInfo: MediaInfo? = localMedia?.info,
|
||||
) {
|
||||
val zoomableState = rememberZoomableState(
|
||||
zoomSpec = ZoomSpec(maxZoomFactor = 5f)
|
||||
)
|
||||
val mimeType = mediaInfo?.mimeType
|
||||
when {
|
||||
mimeType.isMimeTypeImage() -> MediaImageView(
|
||||
localMediaViewState = localMediaViewState,
|
||||
localMedia = localMedia,
|
||||
zoomableState = zoomableState,
|
||||
onReady = onReady,
|
||||
modifier = modifier
|
||||
)
|
||||
mimeType.isMimeTypeVideo() -> MediaVideoView(
|
||||
localMediaViewState = localMediaViewState,
|
||||
localMedia = localMedia,
|
||||
onReady = onReady,
|
||||
modifier = modifier
|
||||
)
|
||||
mimeType == MimeTypes.Pdf -> MediaPDFView(
|
||||
localMediaViewState = localMediaViewState,
|
||||
localMedia = localMedia,
|
||||
zoomableState = zoomableState,
|
||||
onReady = onReady,
|
||||
modifier = modifier
|
||||
)
|
||||
else -> Unit
|
||||
else -> MediaFileView(
|
||||
localMediaViewState = localMediaViewState,
|
||||
uri = localMedia?.uri,
|
||||
info = mediaInfo,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaImageView(
|
||||
localMediaViewState: LocalMediaViewState,
|
||||
localMedia: LocalMedia?,
|
||||
zoomableState: ZoomableState,
|
||||
onReady: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
@@ -98,15 +127,11 @@ private fun MediaImageView(
|
||||
)
|
||||
} else {
|
||||
val zoomableImageState = rememberZoomableImageState(zoomableState)
|
||||
LaunchedEffect(zoomableImageState.isImageDisplayed) {
|
||||
if (zoomableImageState.isImageDisplayed) {
|
||||
onReady()
|
||||
}
|
||||
}
|
||||
localMediaViewState.isReady = zoomableImageState.isImageDisplayed
|
||||
ZoomableAsyncImage(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
state = zoomableImageState,
|
||||
model = localMedia?.model,
|
||||
model = localMedia?.uri,
|
||||
contentDescription = "Image",
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
@@ -116,14 +141,14 @@ private fun MediaImageView(
|
||||
@UnstableApi
|
||||
@Composable
|
||||
fun MediaVideoView(
|
||||
localMediaViewState: LocalMediaViewState,
|
||||
localMedia: LocalMedia?,
|
||||
onReady: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val playerListener = object : Player.Listener {
|
||||
override fun onRenderedFirstFrame() {
|
||||
onReady()
|
||||
localMediaViewState.isReady = true
|
||||
}
|
||||
}
|
||||
val exoPlayer = remember {
|
||||
@@ -170,19 +195,64 @@ fun MediaVideoView(
|
||||
|
||||
@Composable
|
||||
fun MediaPDFView(
|
||||
localMediaViewState: LocalMediaViewState,
|
||||
localMedia: LocalMedia?,
|
||||
zoomableState: ZoomableState,
|
||||
onReady: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val pdfViewerState = rememberPdfViewerState(
|
||||
model = localMedia?.model,
|
||||
model = localMedia?.uri,
|
||||
zoomableState = zoomableState
|
||||
)
|
||||
LaunchedEffect(pdfViewerState.isLoaded) {
|
||||
if (pdfViewerState.isLoaded) {
|
||||
onReady()
|
||||
}
|
||||
}
|
||||
localMediaViewState.isReady = pdfViewerState.isLoaded
|
||||
PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MediaFileView(
|
||||
localMediaViewState: LocalMediaViewState,
|
||||
uri: Uri?,
|
||||
info: MediaInfo?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
localMediaViewState.isReady = uri != null
|
||||
Box(modifier = modifier.padding(horizontal = 8.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.onBackground),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Attachment,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.rotate(-45f),
|
||||
)
|
||||
}
|
||||
if (info != null) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Text(
|
||||
text = info.name,
|
||||
maxLines = 2,
|
||||
fontSize = 16.sp,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
color = ElementTheme.colors.gray1400
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = formatFileExtensionAndSize(info.name, info.formattedFileSize),
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = ElementTheme.colors.gray1400
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.media.local
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
@Stable
|
||||
class LocalMediaViewState {
|
||||
var isReady: Boolean by mutableStateOf(false)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberLocalMediaViewState(): LocalMediaViewState {
|
||||
return remember {
|
||||
LocalMediaViewState()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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.os.Parcelable
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class MediaInfo(
|
||||
val name: String,
|
||||
val mimeType: String,
|
||||
val formattedFileSize: String,
|
||||
) : Parcelable
|
||||
|
||||
fun anImageInfo(): MediaInfo = MediaInfo(
|
||||
"an image file.jpg", MimeTypes.Jpeg, "4MB"
|
||||
)
|
||||
|
||||
fun aVideoInfo(): MediaInfo = MediaInfo(
|
||||
"a video file.mp4", MimeTypes.Mp4, "14MB"
|
||||
)
|
||||
|
||||
fun aPdfInfo(): MediaInfo = MediaInfo(
|
||||
"a pdf file.pdf", MimeTypes.Pdf, "23MB"
|
||||
)
|
||||
|
||||
fun aFileInfo(): MediaInfo = MediaInfo(
|
||||
"an apk file.apk", MimeTypes.Apk, "50MB"
|
||||
)
|
||||
@@ -1,51 +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.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
|
||||
}
|
||||
}
|
||||
@@ -43,11 +43,11 @@ class PdfRendererManager(
|
||||
mutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
pdfRenderer = PdfRenderer(parcelFileDescriptor).apply {
|
||||
(0 until pageCount).map { pageIndex ->
|
||||
PdfPage(width, pageIndex, mutex, this, coroutineScope)
|
||||
}.also {
|
||||
mutablePdfPages.value = it
|
||||
}
|
||||
// Preload just 3 pages so we can render faster
|
||||
val firstPages = loadPages(from = 0, to = 3)
|
||||
mutablePdfPages.value = firstPages
|
||||
val nextPages = loadPages(from = 3, to = pageCount)
|
||||
mutablePdfPages.value = firstPages + nextPages
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,4 +65,10 @@ class PdfRendererManager(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PdfRenderer.loadPages(from: Int, to: Int): List<PdfPage> {
|
||||
return (from until minOf(to, pageCount)).map { pageIndex ->
|
||||
PdfPage(width, pageIndex, mutex, this, coroutineScope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
package io.element.android.features.messages.impl.media.viewer
|
||||
|
||||
sealed interface MediaViewerEvents {
|
||||
object SaveOnDisk: MediaViewerEvents
|
||||
object Share: MediaViewerEvents
|
||||
object OpenWith: MediaViewerEvents
|
||||
object RetryLoading : MediaViewerEvents
|
||||
object ClearLoadingError : MediaViewerEvents
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ 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.media.local.MediaInfo
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.designsystem.theme.ForcedDarkElementTheme
|
||||
@@ -38,10 +39,9 @@ class MediaViewerNode @AssistedInject constructor(
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
data class Inputs(
|
||||
val name: String,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val mimeType: String?
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs: Inputs = inputs()
|
||||
@@ -54,7 +54,8 @@ class MediaViewerNode @AssistedInject constructor(
|
||||
val state = presenter.present()
|
||||
MediaViewerView(
|
||||
state = state,
|
||||
modifier = modifier
|
||||
modifier = modifier,
|
||||
onBackPressed = this::navigateUp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package io.element.android.features.messages.impl.media.viewer
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
@@ -28,18 +29,26 @@ 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.LocalMediaActions
|
||||
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.designsystem.utils.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
||||
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
|
||||
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
|
||||
import io.element.android.libraries.androidutils.R as UtilsR
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
class MediaViewerPresenter @AssistedInject constructor(
|
||||
@Assisted private val inputs: MediaViewerNode.Inputs,
|
||||
private val localMediaFactory: LocalMediaFactory,
|
||||
private val mediaLoader: MatrixMediaLoader,
|
||||
private val localMediaActions: LocalMediaActions,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
) : Presenter<MediaViewerState> {
|
||||
|
||||
@AssistedFactory
|
||||
@@ -57,6 +66,8 @@ class MediaViewerPresenter @AssistedInject constructor(
|
||||
val localMedia: MutableState<Async<LocalMedia>> = remember {
|
||||
mutableStateOf(Async.Uninitialized)
|
||||
}
|
||||
val snackbarMessage = handleSnackbarMessage(snackbarDispatcher)
|
||||
localMediaActions.Configure()
|
||||
DisposableEffect(loadMediaTrigger) {
|
||||
coroutineScope.downloadMedia(mediaFile, localMedia)
|
||||
onDispose {
|
||||
@@ -68,29 +79,84 @@ class MediaViewerPresenter @AssistedInject constructor(
|
||||
when (mediaViewerEvents) {
|
||||
MediaViewerEvents.RetryLoading -> loadMediaTrigger++
|
||||
MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized
|
||||
MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value)
|
||||
MediaViewerEvents.Share -> coroutineScope.share(localMedia.value)
|
||||
MediaViewerEvents.OpenWith -> coroutineScope.open(localMedia.value)
|
||||
}
|
||||
}
|
||||
|
||||
return MediaViewerState(
|
||||
name = inputs.name,
|
||||
mimeType = inputs.mimeType,
|
||||
mediaInfo = inputs.mediaInfo,
|
||||
thumbnailSource = inputs.thumbnailSource,
|
||||
downloadedMedia = localMedia.value,
|
||||
snackbarMessage = snackbarMessage,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.downloadMedia(mediaFile: MutableState<MediaFile?>, localMedia: MutableState<Async<LocalMedia>>) = launch {
|
||||
localMedia.value = Async.Loading()
|
||||
mediaLoader.downloadMediaFile(inputs.mediaSource, inputs.mimeType)
|
||||
mediaLoader.downloadMediaFile(
|
||||
source = inputs.mediaSource,
|
||||
mimeType = inputs.mediaInfo.mimeType,
|
||||
body = inputs.mediaInfo.name
|
||||
)
|
||||
.onSuccess {
|
||||
mediaFile.value = it
|
||||
}.mapCatching {
|
||||
localMediaFactory.createFromMediaFile(it, inputs.mimeType)
|
||||
}.mapCatching { mediaFile ->
|
||||
localMediaFactory.createFromMediaFile(
|
||||
mediaFile = mediaFile,
|
||||
mediaInfo = inputs.mediaInfo
|
||||
)
|
||||
}.onSuccess {
|
||||
localMedia.value = Async.Success(it)
|
||||
}.onFailure {
|
||||
localMedia.value = Async.Failure(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.saveOnDisk(localMedia: Async<LocalMedia>) = launch {
|
||||
if (localMedia is Async.Success) {
|
||||
localMediaActions.saveOnDisk(localMedia.state)
|
||||
.onSuccess {
|
||||
val snackbarMessage = SnackbarMessage(StringR.string.common_file_saved_on_disk_android)
|
||||
snackbarDispatcher.post(snackbarMessage)
|
||||
}
|
||||
.onFailure {
|
||||
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
|
||||
snackbarDispatcher.post(snackbarMessage)
|
||||
}
|
||||
} else Unit
|
||||
}
|
||||
|
||||
private fun CoroutineScope.share(localMedia: Async<LocalMedia>) = launch {
|
||||
if (localMedia is Async.Success) {
|
||||
localMediaActions.share(localMedia.state)
|
||||
.onFailure {
|
||||
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
|
||||
snackbarDispatcher.post(snackbarMessage)
|
||||
}
|
||||
} else Unit
|
||||
}
|
||||
|
||||
private fun CoroutineScope.open(localMedia: Async<LocalMedia>) = launch {
|
||||
if (localMedia is Async.Success) {
|
||||
localMediaActions.open(localMedia.state)
|
||||
.onFailure {
|
||||
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
|
||||
snackbarDispatcher.post(snackbarMessage)
|
||||
}
|
||||
} else Unit
|
||||
}
|
||||
|
||||
private fun mediaActionsError(throwable: Throwable): Int {
|
||||
return if (throwable is ActivityNotFoundException) {
|
||||
UtilsR.string.error_no_compatible_app_found
|
||||
} else {
|
||||
StringR.string.error_unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -17,13 +17,15 @@
|
||||
package io.element.android.features.messages.impl.media.viewer
|
||||
|
||||
import io.element.android.features.messages.impl.media.local.LocalMedia
|
||||
import io.element.android.features.messages.impl.media.local.MediaInfo
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
|
||||
data class MediaViewerState(
|
||||
val name: String,
|
||||
val mimeType: String?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val downloadedMedia: Async<LocalMedia>,
|
||||
val snackbarMessage: SnackbarMessage?,
|
||||
val eventSink: (MediaViewerEvents) -> Unit,
|
||||
)
|
||||
|
||||
@@ -18,8 +18,12 @@ 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.features.messages.impl.media.local.MediaInfo
|
||||
import io.element.android.features.messages.impl.media.local.aFileInfo
|
||||
import io.element.android.features.messages.impl.media.local.aPdfInfo
|
||||
import io.element.android.features.messages.impl.media.local.aVideoInfo
|
||||
import io.element.android.features.messages.impl.media.local.anImageInfo
|
||||
import io.element.android.libraries.architecture.Async
|
||||
|
||||
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
|
||||
@@ -30,24 +34,41 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
|
||||
aMediaViewerState(Async.Failure(IllegalStateException())),
|
||||
aMediaViewerState(
|
||||
Async.Success(
|
||||
LocalMedia(
|
||||
Uri.EMPTY, MimeTypes.IMAGE_JPEG, "an image file", 100L
|
||||
)
|
||||
LocalMedia(Uri.EMPTY, anImageInfo())
|
||||
),
|
||||
anImageInfo(),
|
||||
),
|
||||
aMediaViewerState(
|
||||
Async.Success(
|
||||
LocalMedia(
|
||||
Uri.EMPTY, MimeTypes.VIDEO_MP4, "a video file", 100L
|
||||
)
|
||||
LocalMedia(Uri.EMPTY, aVideoInfo())
|
||||
),
|
||||
aVideoInfo(),
|
||||
),
|
||||
aMediaViewerState(
|
||||
Async.Success(
|
||||
LocalMedia(Uri.EMPTY, aPdfInfo())
|
||||
),
|
||||
aPdfInfo(),
|
||||
),
|
||||
aMediaViewerState(
|
||||
Async.Loading(),
|
||||
aFileInfo(),
|
||||
),
|
||||
aMediaViewerState(
|
||||
Async.Success(
|
||||
LocalMedia(Uri.EMPTY, aFileInfo())
|
||||
),
|
||||
aFileInfo(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun aMediaViewerState(downloadedMedia: Async<LocalMedia> = Async.Uninitialized) = MediaViewerState(
|
||||
name = "A media",
|
||||
mimeType = MimeTypes.IMAGE_JPEG,
|
||||
fun aMediaViewerState(
|
||||
downloadedMedia: Async<LocalMedia> = Async.Uninitialized,
|
||||
mediaInfo: MediaInfo = anImageInfo(),
|
||||
) = MediaViewerState(
|
||||
mediaInfo = mediaInfo,
|
||||
thumbnailSource = null,
|
||||
downloadedMedia = downloadedMedia,
|
||||
snackbarMessage = null
|
||||
) {}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package io.element.android.features.messages.impl.media.viewer
|
||||
|
||||
@@ -21,8 +22,21 @@ 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.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -32,18 +46,25 @@ 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.platform.LocalInspectionMode
|
||||
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 coil.compose.AsyncImage
|
||||
import io.element.android.features.messages.impl.media.local.LocalMedia
|
||||
import io.element.android.features.messages.impl.media.local.LocalMediaView
|
||||
import io.element.android.features.messages.impl.media.local.rememberLocalMediaViewState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.isLoading
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
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.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -52,6 +73,7 @@ import io.element.android.libraries.ui.strings.R as StringR
|
||||
@Composable
|
||||
fun MediaViewerView(
|
||||
state: MediaViewerState,
|
||||
onBackPressed: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
||||
@@ -63,61 +85,132 @@ fun MediaViewerView(
|
||||
state.eventSink(MediaViewerEvents.ClearLoadingError)
|
||||
}
|
||||
|
||||
var showProgress by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val localMediaViewState = rememberLocalMediaViewState()
|
||||
val showThumbnail = !localMediaViewState.isReady
|
||||
val showProgress = rememberShowProgress(state.downloadedMedia)
|
||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||
|
||||
// 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(
|
||||
Scaffold(
|
||||
modifier,
|
||||
topBar = {
|
||||
MediaViewerTopBar(
|
||||
actionsEnabled = state.downloadedMedia is Async.Success,
|
||||
onBackPressed = onBackPressed,
|
||||
eventSink = state.eventSink
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
SnackbarHost(snackbarHostState) { data ->
|
||||
Snackbar(
|
||||
snackbarData = data,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
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
|
||||
if (showProgress) {
|
||||
LinearProgressIndicator(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (state.downloadedMedia is Async.Failure) {
|
||||
ErrorView(
|
||||
errorMessage = stringResource(id = StringR.string.error_unknown),
|
||||
onRetry = ::onRetry,
|
||||
onDismiss = ::onDismissError
|
||||
)
|
||||
}
|
||||
LocalMediaView(
|
||||
localMediaViewState = localMediaViewState,
|
||||
localMedia = state.downloadedMedia.dataOrNull(),
|
||||
mediaInfo = state.mediaInfo,
|
||||
)
|
||||
ThumbnailView(
|
||||
thumbnailSource = state.thumbnailSource,
|
||||
showThumbnail = showThumbnail,
|
||||
)
|
||||
}
|
||||
LocalMediaView(
|
||||
localMedia = state.downloadedMedia.dataOrNull(),
|
||||
mimeType = state.mimeType,
|
||||
onReady = ::onMediaReady
|
||||
)
|
||||
ThumbnailView(
|
||||
thumbnailSource = state.thumbnailSource,
|
||||
showThumbnail = showThumbnail,
|
||||
showProgress = showProgress,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberShowProgress(downloadedMedia: Async<LocalMedia>): Boolean {
|
||||
var showProgress by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
if (LocalInspectionMode.current) {
|
||||
showProgress = downloadedMedia.isLoading()
|
||||
} else {
|
||||
// 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(downloadedMedia) {
|
||||
showProgress = false
|
||||
delay(100)
|
||||
if (downloadedMedia.isLoading()) {
|
||||
showProgress = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return showProgress
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaViewerTopBar(
|
||||
actionsEnabled: Boolean,
|
||||
onBackPressed: () -> Unit,
|
||||
eventSink: (MediaViewerEvents) -> Unit,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = { BackButton(onClick = onBackPressed) },
|
||||
actions = {
|
||||
IconButton(
|
||||
enabled = actionsEnabled,
|
||||
onClick = {
|
||||
eventSink(MediaViewerEvents.OpenWith)
|
||||
},
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.OpenInNew, contentDescription = stringResource(id = StringR.string.action_open_with))
|
||||
}
|
||||
IconButton(
|
||||
enabled = actionsEnabled,
|
||||
onClick = {
|
||||
eventSink(MediaViewerEvents.SaveOnDisk)
|
||||
},
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Download, contentDescription = stringResource(id = StringR.string.action_save))
|
||||
}
|
||||
IconButton(
|
||||
enabled = actionsEnabled,
|
||||
onClick = {
|
||||
eventSink(MediaViewerEvents.Share)
|
||||
},
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Share, contentDescription = stringResource(id = StringR.string.action_share))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThumbnailView(
|
||||
thumbnailSource: MediaSource?,
|
||||
showThumbnail: Boolean,
|
||||
showProgress: Boolean,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = showThumbnail,
|
||||
@@ -139,14 +232,6 @@ private fun ThumbnailView(
|
||||
contentScale = ContentScale.Fit,
|
||||
contentDescription = null,
|
||||
)
|
||||
if (showProgress) {
|
||||
Box(
|
||||
modifier = Modifier.roundedBackground(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,5 +260,6 @@ fun MediaViewerViewDarkPreview(@PreviewParameter(MediaViewerStateProvider::class
|
||||
private fun ContentToPreview(state: MediaViewerState) {
|
||||
MediaViewerView(
|
||||
state = state,
|
||||
onBackPressed = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ class MessageComposerPresenter @Inject constructor(
|
||||
is Attachment.Media -> {
|
||||
sendMedia(
|
||||
uri = attachment.localMedia.uri,
|
||||
mimeType = attachment.localMedia.mimeType,
|
||||
mimeType = attachment.localMedia.info.mimeType,
|
||||
attachmentState = attachmentState
|
||||
)
|
||||
}
|
||||
@@ -210,12 +210,17 @@ class MessageComposerPresenter @Inject constructor(
|
||||
attachmentsState.value = AttachmentsState.None
|
||||
return
|
||||
}
|
||||
val localMedia = localMediaFactory.createFromUri(uri, mimeType)
|
||||
val localMedia = localMediaFactory.createFromUri(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
name = null,
|
||||
formattedFileSize = null
|
||||
)
|
||||
val mediaAttachment = Attachment.Media(localMedia, compressIfPossible)
|
||||
val isPreviewable = when {
|
||||
MimeTypes.isImage(localMedia.mimeType) -> true
|
||||
MimeTypes.isVideo(localMedia.mimeType) -> true
|
||||
MimeTypes.isAudio(localMedia.mimeType) -> true
|
||||
MimeTypes.isImage(localMedia.info.mimeType) -> true
|
||||
MimeTypes.isVideo(localMedia.info.mimeType) -> true
|
||||
MimeTypes.isAudio(localMedia.info.mimeType) -> true
|
||||
else -> false
|
||||
}
|
||||
attachmentsState.value = if (isPreviewable) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlin.random.Random
|
||||
@@ -96,6 +97,7 @@ internal fun aTimelineItemEvent(
|
||||
content: TimelineItemEventContent = aTimelineItemTextContent(),
|
||||
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
||||
sendState: EventSendState = EventSendState.Sent(eventId),
|
||||
inReplyTo: InReplyTo? = null,
|
||||
): TimelineItem.Event {
|
||||
return TimelineItem.Event(
|
||||
id = eventId.value,
|
||||
@@ -113,5 +115,6 @@ internal fun aTimelineItemEvent(
|
||||
senderDisplayName = "sender",
|
||||
groupPosition = groupPosition,
|
||||
sendState = sendState,
|
||||
inReplyTo = inReplyTo,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,30 +17,16 @@
|
||||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -55,39 +41,24 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.LastBaseline
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
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.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.event.TimelineItemEventContentView
|
||||
import io.element.android.features.messages.impl.timeline.components.TimelineItemEventRow
|
||||
import io.element.android.features.messages.impl.timeline.components.TimelineItemStateEventRow
|
||||
import io.element.android.features.messages.impl.timeline.components.TimelineItemVirtualRow
|
||||
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
|
||||
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.event.TimelineItemEventContent
|
||||
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.TimelineItemVideoContent
|
||||
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.util.defaultTimelineContentPadding
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
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.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -101,12 +72,16 @@ fun TimelineView(
|
||||
onMessageClicked: (TimelineItem.Event) -> Unit = {},
|
||||
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
|
||||
) {
|
||||
|
||||
fun onReachedLoadMore() {
|
||||
state.eventSink(TimelineEvents.LoadMore)
|
||||
}
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
||||
fun inReplyToClicked(eventId: EventId) {
|
||||
// TODO implement this logic once we have support to 'jump to event X' in sliding sync
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -124,6 +99,7 @@ fun TimelineView(
|
||||
onClick = onMessageClicked,
|
||||
onLongClick = onMessageLongClicked,
|
||||
onUserDataClick = onUserDataClicked,
|
||||
inReplyToClick = ::inReplyToClicked,
|
||||
)
|
||||
if (index == state.timelineItems.lastIndex) {
|
||||
onReachedLoadMore()
|
||||
@@ -146,6 +122,7 @@ fun TimelineItemRow(
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onClick: (TimelineItem.Event) -> Unit,
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
when (timelineItem) {
|
||||
@@ -179,6 +156,7 @@ fun TimelineItemRow(
|
||||
onClick = ::onClick,
|
||||
onLongClick = ::onLongClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -209,6 +187,7 @@ fun TimelineItemRow(
|
||||
highlightedItem = highlightedItem,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
)
|
||||
}
|
||||
@@ -219,208 +198,6 @@ fun TimelineItemRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TimelineItemVirtualRow(
|
||||
virtual: TimelineItem.Virtual,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
when (virtual.model) {
|
||||
is TimelineItemLoadingModel -> TimelineLoadingMoreIndicator(modifier)
|
||||
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
|
||||
else -> return
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TimelineItemEventRow(
|
||||
event: TimelineItem.Event,
|
||||
isHighlighted: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
fun onUserDataClicked() {
|
||||
onUserDataClick(event.senderId)
|
||||
}
|
||||
|
||||
val (parentAlignment, contentAlignment) = if (event.isMine) {
|
||||
Pair(Alignment.CenterEnd, Alignment.End)
|
||||
} else {
|
||||
Pair(Alignment.CenterStart, Alignment.Start)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
contentAlignment = parentAlignment
|
||||
) {
|
||||
Row {
|
||||
if (!event.isMine) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
Column(horizontalAlignment = contentAlignment) {
|
||||
if (event.showSenderInformation) {
|
||||
MessageSenderInformation(
|
||||
event.safeSenderName,
|
||||
event.senderAvatar,
|
||||
Modifier
|
||||
.zIndex(1f)
|
||||
.offset(y = 12.dp)
|
||||
.clickable(onClick = ::onUserDataClicked)
|
||||
)
|
||||
}
|
||||
val bubbleState = BubbleState(
|
||||
groupPosition = event.groupPosition,
|
||||
isMine = event.isMine,
|
||||
isHighlighted = isHighlighted,
|
||||
)
|
||||
MessageEventBubble(
|
||||
state = bubbleState,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier
|
||||
.zIndex(-1f)
|
||||
.widthIn(max = 320.dp)
|
||||
) {
|
||||
MessageEventBubbleContent(
|
||||
event = event,
|
||||
interactionSource = interactionSource,
|
||||
onMessageClick = onClick,
|
||||
onMessageLongClick = onLongClick
|
||||
)
|
||||
}
|
||||
TimelineItemReactionsView(
|
||||
reactionsState = event.reactionsState,
|
||||
modifier = Modifier
|
||||
.zIndex(1f)
|
||||
.offset(x = if (event.isMine) 0.dp else 20.dp, y = -(4.dp))
|
||||
)
|
||||
}
|
||||
if (event.isMine) {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.groupPosition.isNew()) {
|
||||
Spacer(modifier = modifier.height(8.dp))
|
||||
} else {
|
||||
Spacer(modifier = modifier.height(2.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TimelineItemStateEventRow(
|
||||
event: TimelineItem.Event,
|
||||
isHighlighted: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
MessageStateEventContainer(
|
||||
isHighlighted = isHighlighted,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier
|
||||
.zIndex(-1f)
|
||||
.widthIn(max = 320.dp)
|
||||
) {
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier.defaultTimelineContentPadding()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageEventBubbleContent(
|
||||
event: TimelineItem.Event,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onMessageClick: () -> Unit,
|
||||
onMessageLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val showTimestampWithOverlay = event.content is TimelineItemImageContent || event.content is TimelineItemVideoContent
|
||||
|
||||
@Composable
|
||||
fun ContentView(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onMessageClick,
|
||||
onLongClick = onMessageLongClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageSenderInformation(
|
||||
sender: String,
|
||||
senderAvatar: AvatarData?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(modifier = modifier) {
|
||||
if (senderAvatar != null) {
|
||||
Avatar(senderAvatar)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
Text(
|
||||
text = sender,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier
|
||||
.alignBy(LastBaseline)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun BoxScope.TimelineScrollHelper(
|
||||
lazyListState: LazyListState,
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
/*
|
||||
* 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.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.LastBaseline
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||
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.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||
import io.element.android.libraries.designsystem.components.EqualWidthColumn
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.theme.LocalColors
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.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.InReplyTo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||
|
||||
@Composable
|
||||
fun TimelineItemEventRow(
|
||||
event: TimelineItem.Event,
|
||||
isHighlighted: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
fun onUserDataClicked() {
|
||||
onUserDataClick(event.senderId)
|
||||
}
|
||||
|
||||
fun inReplayToClicked() {
|
||||
val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return
|
||||
inReplyToClick(inReplyToEventId)
|
||||
}
|
||||
|
||||
val (parentAlignment, contentAlignment) = if (event.isMine) {
|
||||
Pair(Alignment.CenterEnd, Alignment.End)
|
||||
} else {
|
||||
Pair(Alignment.CenterStart, Alignment.Start)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
contentAlignment = parentAlignment
|
||||
) {
|
||||
Row {
|
||||
if (!event.isMine) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
Column(horizontalAlignment = contentAlignment) {
|
||||
if (event.showSenderInformation) {
|
||||
MessageSenderInformation(
|
||||
event.safeSenderName,
|
||||
event.senderAvatar,
|
||||
Modifier
|
||||
.zIndex(1f)
|
||||
.offset(y = 12.dp)
|
||||
.clickable(onClick = ::onUserDataClicked)
|
||||
)
|
||||
}
|
||||
val bubbleState = BubbleState(
|
||||
groupPosition = event.groupPosition,
|
||||
isMine = event.isMine,
|
||||
isHighlighted = isHighlighted,
|
||||
)
|
||||
MessageEventBubble(
|
||||
state = bubbleState,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier
|
||||
.zIndex(-1f)
|
||||
.widthIn(max = 320.dp)
|
||||
) {
|
||||
MessageEventBubbleContent(
|
||||
event = event,
|
||||
interactionSource = interactionSource,
|
||||
onMessageClick = onClick,
|
||||
onMessageLongClick = onLongClick,
|
||||
inReplyToClick = ::inReplayToClicked,
|
||||
)
|
||||
}
|
||||
TimelineItemReactionsView(
|
||||
reactionsState = event.reactionsState,
|
||||
modifier = Modifier
|
||||
.zIndex(1f)
|
||||
.offset(x = if (event.isMine) 0.dp else 20.dp, y = -(4.dp))
|
||||
)
|
||||
}
|
||||
if (event.isMine) {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.groupPosition.isNew()) {
|
||||
Spacer(modifier = modifier.height(8.dp))
|
||||
} else {
|
||||
Spacer(modifier = modifier.height(2.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageSenderInformation(
|
||||
sender: String,
|
||||
senderAvatar: AvatarData?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(modifier = modifier) {
|
||||
if (senderAvatar != null) {
|
||||
Avatar(senderAvatar)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
Text(
|
||||
text = sender,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier
|
||||
.alignBy(LastBaseline)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageEventBubbleContent(
|
||||
event: TimelineItem.Event,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onMessageClick: () -> Unit,
|
||||
onMessageLongClick: () -> Unit,
|
||||
inReplyToClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isMediaItem = event.content is TimelineItemImageContent || event.content is TimelineItemVideoContent
|
||||
val replyToDetails = event.inReplyTo as? InReplyTo.Ready
|
||||
|
||||
@Composable
|
||||
fun ContentView(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onMessageClick,
|
||||
onLongClick = onMessageLongClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContentAndTimestampView(
|
||||
overlayTimestamp: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
contentModifier: Modifier = Modifier,
|
||||
timestampModifier: Modifier = Modifier,
|
||||
) {
|
||||
if (overlayTimestamp) {
|
||||
Box(modifier) {
|
||||
ContentView(modifier = contentModifier)
|
||||
TimelineEventTimestampView(
|
||||
event = event,
|
||||
onClick = onMessageClick,
|
||||
modifier = timestampModifier
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp) // Outer padding
|
||||
.background(LocalColors.current.gray300, RoundedCornerShape(10.0.dp))
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp) // Inner padding
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(modifier) {
|
||||
ContentView(modifier = contentModifier.padding(start = 12.dp, end = 12.dp, top = 8.dp))
|
||||
TimelineEventTimestampView(
|
||||
event = event,
|
||||
onClick = onMessageClick,
|
||||
modifier = timestampModifier
|
||||
.align(Alignment.End)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Used only for media items, with no reply to metadata. It displays the contents with no paddings. */
|
||||
@Composable
|
||||
fun SimpleMediaItemLayout(modifier: Modifier = Modifier) {
|
||||
ContentAndTimestampView(overlayTimestamp = true, modifier = modifier)
|
||||
}
|
||||
|
||||
/** Used for every other type of message, groups the different components in a Column with some space between them. */
|
||||
@Composable
|
||||
fun CommonLayout(
|
||||
inReplyToDetails: InReplyTo.Ready?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
EqualWidthColumn(modifier = modifier, spacing = 8.dp) {
|
||||
if (inReplyToDetails != null) {
|
||||
val senderName = event.senderDisplayName ?: event.senderId.value
|
||||
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToDetails)
|
||||
ReplyToContent(
|
||||
senderName = senderName,
|
||||
text = inReplyToDetails.content.body,
|
||||
attachmentThumbnailInfo = attachmentThumbnailInfo,
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp, start = 8.dp, end = 8.dp)
|
||||
.clickable(enabled = true, onClick = inReplyToClick),
|
||||
)
|
||||
}
|
||||
val modifierWithPadding = if (isMediaItem) {
|
||||
Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
val contentModifier = if (isMediaItem) {
|
||||
Modifier.clip(RoundedCornerShape(12.dp))
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
ContentAndTimestampView(
|
||||
overlayTimestamp = isMediaItem,
|
||||
contentModifier = contentModifier,
|
||||
modifier = modifierWithPadding,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isMediaItem && replyToDetails == null) {
|
||||
SimpleMediaItemLayout()
|
||||
} else {
|
||||
CommonLayout(inReplyToDetails = replyToDetails, modifier = modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyToContent(
|
||||
senderName: String,
|
||||
text: String?,
|
||||
attachmentThumbnailInfo: AttachmentThumbnailInfo?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val paddings = if (attachmentThumbnailInfo != null) {
|
||||
PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp)
|
||||
} else {
|
||||
PaddingValues(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp)
|
||||
}
|
||||
Row(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(paddings)
|
||||
) {
|
||||
if (attachmentThumbnailInfo != null) {
|
||||
AttachmentThumbnail(
|
||||
info = attachmentThumbnailInfo,
|
||||
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
senderName,
|
||||
style = ElementTextStyles.Regular.caption2.copy(fontWeight = FontWeight.Medium),
|
||||
textAlign = TextAlign.Start,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = text.orEmpty(),
|
||||
style = ElementTextStyles.Regular.caption1,
|
||||
textAlign = TextAlign.Start,
|
||||
color = LocalColors.current.placeholder,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready) =
|
||||
when (val type = inReplyTo.content.type) {
|
||||
is ImageMessageType -> AttachmentThumbnailInfo(
|
||||
mediaSource = type.info?.thumbnailSource,
|
||||
textContent = inReplyTo.content.body,
|
||||
type = AttachmentThumbnailType.Image,
|
||||
blurHash = type.info?.blurhash,
|
||||
)
|
||||
is VideoMessageType -> AttachmentThumbnailInfo(
|
||||
mediaSource = type.info?.thumbnailSource,
|
||||
textContent = inReplyTo.content.body,
|
||||
type = AttachmentThumbnailType.Video,
|
||||
blurHash = type.info?.blurhash,
|
||||
)
|
||||
is FileMessageType -> AttachmentThumbnailInfo(
|
||||
mediaSource = type.info?.thumbnailSource,
|
||||
textContent = inReplyTo.content.body,
|
||||
type = AttachmentThumbnailType.File,
|
||||
blurHash = null,
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding
|
||||
|
||||
@Composable
|
||||
fun TimelineItemStateEventRow(
|
||||
event: TimelineItem.Event,
|
||||
isHighlighted: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
MessageStateEventContainer(
|
||||
isHighlighted = isHighlighted,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier
|
||||
.zIndex(-1f)
|
||||
.widthIn(max = 320.dp)
|
||||
) {
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier.defaultTimelineContentPadding()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.impl.timeline.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingModel
|
||||
|
||||
@Composable
|
||||
fun TimelineItemVirtualRow(
|
||||
virtual: TimelineItem.Virtual,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
when (virtual.model) {
|
||||
is TimelineItemLoadingModel -> TimelineLoadingMoreIndicator(modifier)
|
||||
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
|
||||
else -> return
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,9 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Attachment
|
||||
@@ -61,10 +62,13 @@ fun TimelineItemFileView(
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Attachment,
|
||||
contentDescription = "OpenFile",
|
||||
modifier = Modifier.size(16.dp).rotate(-45f),
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.rotate(-45f),
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp),) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = content.body,
|
||||
maxLines = 2,
|
||||
@@ -74,6 +78,9 @@ fun TimelineItemFileView(
|
||||
Text(
|
||||
text = content.fileExtensionAndSize,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,20 +22,23 @@ import androidx.compose.ui.Modifier
|
||||
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.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
|
||||
import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
|
||||
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
|
||||
import kotlin.math.max
|
||||
|
||||
@Composable
|
||||
fun TimelineItemImageView(
|
||||
content: TimelineItemImageContent,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// TODO place this value somewhere else?
|
||||
val minHeight = max(100, content.height ?: 0)
|
||||
TimelineItemAspectRatioBox(
|
||||
height = content.height,
|
||||
height = minHeight,
|
||||
aspectRatio = content.aspectRatio,
|
||||
modifier = modifier
|
||||
) {
|
||||
|
||||
@@ -29,9 +29,9 @@ 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.components.BlurHashAsyncImage
|
||||
import io.element.android.libraries.designsystem.modifiers.roundedBackground
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
|
||||
@@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter
|
||||
import io.element.android.features.messages.impl.timeline.util.toHtmlDocument
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
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
|
||||
@@ -51,11 +52,12 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||
TimelineItemImageContent(
|
||||
body = messageType.body,
|
||||
mediaSource = messageType.source,
|
||||
mimeType = messageType.info?.mimetype,
|
||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
blurhash = messageType.info?.blurhash,
|
||||
width = messageType.info?.width?.toInt(),
|
||||
height = messageType.info?.height?.toInt(),
|
||||
aspectRatio = aspectRatio
|
||||
aspectRatio = aspectRatio,
|
||||
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0)
|
||||
)
|
||||
}
|
||||
is VideoMessageType -> {
|
||||
@@ -64,22 +66,21 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||
body = messageType.body,
|
||||
thumbnailSource = messageType.info?.thumbnailSource,
|
||||
videoSource = messageType.source,
|
||||
mimeType = messageType.info?.mimetype,
|
||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
width = messageType.info?.width?.toInt(),
|
||||
height = messageType.info?.height?.toInt(),
|
||||
duration = messageType.info?.duration ?: 0L,
|
||||
blurHash = messageType.info?.blurhash,
|
||||
aspectRatio = aspectRatio
|
||||
aspectRatio = aspectRatio,
|
||||
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0)
|
||||
)
|
||||
}
|
||||
is FileMessageType -> TimelineItemFileContent(
|
||||
body = messageType.body,
|
||||
thumbnailSource = messageType.info?.thumbnailSource,
|
||||
fileSource = messageType.source,
|
||||
mimeType = messageType.info?.mimetype,
|
||||
formattedFileSize = messageType.info?.size?.let {
|
||||
fileSizeFormatter.format(it)
|
||||
},
|
||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0)
|
||||
)
|
||||
is NoticeMessageType -> TimelineItemNoticeContent(
|
||||
body = messageType.body,
|
||||
|
||||
@@ -81,6 +81,7 @@ class TimelineItemEventFactory @Inject constructor(
|
||||
groupPosition = groupPosition,
|
||||
reactionsState = currentTimelineItem.computeReactionsState(),
|
||||
sendState = currentTimelineItem.event.localSendState ?: EventSendState.NotSentYet,
|
||||
inReplyTo = currentTimelineItem.event.inReplyTo(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
@@ -59,6 +60,7 @@ sealed interface TimelineItem {
|
||||
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
||||
val reactionsState: TimelineItemReactions,
|
||||
val sendState: EventSendState,
|
||||
val inReplyTo: InReplyTo?,
|
||||
) : TimelineItem {
|
||||
|
||||
val showSenderInformation = groupPosition.isNew() && !isMine
|
||||
|
||||
@@ -16,23 +16,17 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.model.event
|
||||
|
||||
import io.element.android.features.messages.impl.media.helper.formatFileExtensionAndSize
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
|
||||
data class TimelineItemFileContent(
|
||||
val body: String,
|
||||
val fileSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val formattedFileSize: String?,
|
||||
val mimeType: String?,
|
||||
val formattedFileSize: String,
|
||||
val mimeType: String,
|
||||
) : TimelineItemEventContent {
|
||||
override val type: String = "TimelineItemFileContent"
|
||||
|
||||
private val fileExtension = body.substringAfterLast('.', "").uppercase()
|
||||
val fileExtensionAndSize = buildString {
|
||||
append(fileExtension)
|
||||
if (formattedFileSize != null) {
|
||||
append(' ')
|
||||
append("($formattedFileSize)")
|
||||
}
|
||||
}
|
||||
val fileExtensionAndSize = formatFileExtensionAndSize(body, formattedFileSize)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
data class TimelineItemImageContent(
|
||||
val body: String,
|
||||
val mediaSource: MediaSource,
|
||||
val mimeType: String?,
|
||||
val formattedFileSize: String,
|
||||
val mimeType: String,
|
||||
val blurhash: String?,
|
||||
val width: Int?,
|
||||
val height: Int?,
|
||||
|
||||
@@ -36,5 +36,6 @@ fun aTimelineItemImageContent() = TimelineItemImageContent(
|
||||
blurhash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
|
||||
width = null,
|
||||
height = 300,
|
||||
aspectRatio = 0.5f
|
||||
aspectRatio = 0.5f,
|
||||
formattedFileSize = "4MB"
|
||||
)
|
||||
|
||||
@@ -27,7 +27,8 @@ data class TimelineItemVideoContent(
|
||||
val blurHash: String?,
|
||||
val height: Int?,
|
||||
val width: Int?,
|
||||
val mimeType: String?,
|
||||
val mimeType: String,
|
||||
val formattedFileSize: String,
|
||||
) : TimelineItemEventContent {
|
||||
override val type: String = "TimelineItemImageContent"
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
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 TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineItemVideoContent> {
|
||||
@@ -37,5 +38,6 @@ fun aTimelineItemVideoContent() = TimelineItemVideoContent(
|
||||
videoSource = MediaSource(""),
|
||||
height = 300,
|
||||
width = 150,
|
||||
mimeType = null
|
||||
mimeType = MimeTypes.Mp4,
|
||||
formattedFileSize = "14MB"
|
||||
)
|
||||
|
||||
@@ -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.utils.messagesummary
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
|
||||
interface MessageSummaryFormatter {
|
||||
fun format(event: TimelineItem.Event): String
|
||||
}
|
||||
@@ -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.utils.messagesummary
|
||||
|
||||
import android.content.Context
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
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.TimelineItemProfileChangeContent
|
||||
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.TimelineItemTextBasedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.ui.strings.R
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(RoomScope::class)
|
||||
class MessageSummaryFormatterImpl @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : MessageSummaryFormatter {
|
||||
override fun format(event: TimelineItem.Event): String {
|
||||
return when (event.content) {
|
||||
is TimelineItemTextBasedContent -> event.content.body
|
||||
is TimelineItemStateContent -> event.content.body
|
||||
is TimelineItemProfileChangeContent -> event.content.body
|
||||
is TimelineItemEncryptedContent -> context.getString(R.string.common_unable_to_decrypt)
|
||||
is TimelineItemRedactedContent -> context.getString(R.string.common_message_removed)
|
||||
is TimelineItemUnknownContent -> context.getString(R.string.common_unsupported_event)
|
||||
is TimelineItemImageContent -> context.getString(R.string.common_image)
|
||||
is TimelineItemVideoContent -> context.getString(R.string.common_video)
|
||||
is TimelineItemFileContent -> context.getString(R.string.common_file)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,6 @@
|
||||
<string name="screen_room_attachment_source_camera_video">"Record a video"</string>
|
||||
<string name="screen_room_attachment_source_files">"Attachment"</string>
|
||||
<string name="screen_room_attachment_source_gallery">"Photo & Video Library"</string>
|
||||
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
|
||||
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
|
||||
</resources>
|
||||
@@ -29,13 +29,21 @@ 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.messagecomposer.MessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||
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.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatterImpl
|
||||
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
|
||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.core.meta.BuildType
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
@@ -105,6 +113,105 @@ class MessagesPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action reply to an event with no id does nothing`() = runTest {
|
||||
val presenter = createMessagePresenter()
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null)))
|
||||
skipItems(1)
|
||||
// Otherwise we would have some extra items here
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action reply to an image media message`() = runTest {
|
||||
val presenter = createMessagePresenter()
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
val mediaMessage = aMessageEvent(
|
||||
content = TimelineItemImageContent(
|
||||
body = "image.jpg",
|
||||
mediaSource = MediaSource(AN_AVATAR_URL),
|
||||
mimeType = MimeTypes.Jpeg,
|
||||
blurhash = null,
|
||||
width = 20,
|
||||
height = 20,
|
||||
aspectRatio = 1.0f,
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage))
|
||||
skipItems(1)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
||||
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
|
||||
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action reply to a video media message`() = runTest {
|
||||
val presenter = createMessagePresenter()
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
val mediaMessage = aMessageEvent(
|
||||
content = TimelineItemVideoContent(
|
||||
body = "video.mp4",
|
||||
duration = 10L,
|
||||
videoSource = MediaSource(AN_AVATAR_URL),
|
||||
thumbnailSource = MediaSource(AN_AVATAR_URL),
|
||||
mimeType = MimeTypes.Mp4,
|
||||
blurHash = null,
|
||||
width = 20,
|
||||
height = 20,
|
||||
aspectRatio = 1.0f,
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage))
|
||||
skipItems(1)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
||||
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
|
||||
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action reply to a file media message`() = runTest {
|
||||
val presenter = createMessagePresenter()
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
val mediaMessage = aMessageEvent(
|
||||
content = TimelineItemFileContent(
|
||||
body = "video.mp4",
|
||||
fileSource = MediaSource(AN_AVATAR_URL),
|
||||
thumbnailSource = MediaSource(AN_AVATAR_URL),
|
||||
formattedFileSize = "10 MB",
|
||||
mimeType = MimeTypes.Pdf,
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage))
|
||||
skipItems(1)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
||||
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
|
||||
assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action edit`() = runTest {
|
||||
val presenter = createMessagePresenter()
|
||||
@@ -197,6 +304,7 @@ class MessagesPresenterTest {
|
||||
actionListPresenter = actionListPresenter,
|
||||
networkMonitor = FakeNetworkMonitor(),
|
||||
snackbarDispatcher = SnackbarDispatcher(),
|
||||
messageSummaryFormatter = FakeMessageSummaryFormatter(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,5 +245,6 @@ private fun aMessageEvent(
|
||||
sentTime = "",
|
||||
isMine = isMine,
|
||||
reactionsState = TimelineItemReactions(persistentListOf()),
|
||||
sendState = EventSendState.Sent(AN_EVENT_ID)
|
||||
sendState = EventSendState.Sent(AN_EVENT_ID),
|
||||
inReplyTo = null,
|
||||
)
|
||||
|
||||
@@ -92,7 +92,6 @@ class AttachmentsPreviewPresenterTest {
|
||||
private fun anAttachmentsPreviewPresenter(
|
||||
localMedia: LocalMedia = aLocalMedia(
|
||||
uri = mockMediaUrl,
|
||||
mimeType = MimeTypes.IMAGE_JPEG
|
||||
),
|
||||
room: MatrixRoom = FakeMatrixRoom()
|
||||
): AttachmentsPreviewPresenter {
|
||||
|
||||
@@ -21,7 +21,9 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemReac
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
@@ -29,11 +31,13 @@ import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
internal fun aMessageEvent(
|
||||
eventId: EventId? = AN_EVENT_ID,
|
||||
isMine: Boolean = true,
|
||||
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
|
||||
inReplyTo: InReplyTo? = null,
|
||||
) = TimelineItem.Event(
|
||||
id = AN_EVENT_ID.value,
|
||||
eventId = AN_EVENT_ID,
|
||||
id = eventId?.value.orEmpty(),
|
||||
eventId = eventId,
|
||||
senderId = A_USER_ID,
|
||||
senderDisplayName = A_USER_NAME,
|
||||
senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME),
|
||||
@@ -41,5 +45,6 @@ internal fun aMessageEvent(
|
||||
sentTime = "",
|
||||
isMine = isMine,
|
||||
reactionsState = TimelineItemReactions(persistentListOf()),
|
||||
sendState = EventSendState.Sent(AN_EVENT_ID)
|
||||
sendState = EventSendState.Sent(AN_EVENT_ID),
|
||||
inReplyTo = inReplyTo,
|
||||
)
|
||||
|
||||
@@ -17,21 +17,18 @@
|
||||
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
|
||||
import io.element.android.features.messages.impl.media.local.MediaInfo
|
||||
import io.element.android.features.messages.impl.media.local.anImageInfo
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
|
||||
fun aLocalMedia(
|
||||
uri: Uri,
|
||||
mimeType: String = MimeTypes.IMAGE_JPEG,
|
||||
name: String = "a media",
|
||||
size: Long = 1000,
|
||||
mediaInfo: MediaInfo = anImageInfo(),
|
||||
) = LocalMedia(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
name = name,
|
||||
size = size,
|
||||
info = mediaInfo
|
||||
)
|
||||
|
||||
fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media(
|
||||
|
||||
@@ -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.media
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.element.android.features.messages.impl.media.local.LocalMedia
|
||||
import io.element.android.features.messages.impl.media.local.LocalMediaActions
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class FakeLocalMediaActions(private val coroutineDispatchers: CoroutineDispatchers) : LocalMediaActions {
|
||||
|
||||
var shouldFail = false
|
||||
|
||||
@Composable
|
||||
override fun Configure() {
|
||||
//NOOP
|
||||
}
|
||||
|
||||
override suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||
if (shouldFail) {
|
||||
Result.failure(RuntimeException())
|
||||
} else {
|
||||
Result.success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun share(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||
if (shouldFail) {
|
||||
Result.failure(RuntimeException())
|
||||
} else {
|
||||
Result.success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun open(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||
if (shouldFail) {
|
||||
Result.failure(RuntimeException())
|
||||
} else {
|
||||
Result.success(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,18 +20,26 @@ 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.features.messages.impl.media.local.MediaInfo
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||
|
||||
class FakeLocalMediaFactory(private val localMediaUri: Uri) : LocalMediaFactory {
|
||||
|
||||
var fallbackMimeType: String = MimeTypes.OctetStream
|
||||
var fallbackName: String = "File name"
|
||||
var fallbackFileSize = "0B"
|
||||
|
||||
override fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia {
|
||||
return aLocalMedia(uri = localMediaUri, mimeType = mimeType ?: fallbackMimeType)
|
||||
override fun createFromMediaFile(mediaFile: MediaFile, mediaInfo: MediaInfo): LocalMedia {
|
||||
return aLocalMedia(uri = localMediaUri, mediaInfo = mediaInfo)
|
||||
}
|
||||
|
||||
override fun createFromUri(uri: Uri, mimeType: String?): LocalMedia {
|
||||
return aLocalMedia(uri, mimeType ?: fallbackMimeType)
|
||||
override fun createFromUri(uri: Uri, mimeType: String?, name: String?, formattedFileSize: String?): LocalMedia {
|
||||
val mediaInfo = MediaInfo(
|
||||
name = name ?: fallbackName,
|
||||
mimeType = mimeType ?: fallbackMimeType,
|
||||
formattedFileSize = formattedFileSize ?: fallbackFileSize
|
||||
)
|
||||
return aLocalMedia(uri, mediaInfo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,65 +19,122 @@
|
||||
package io.element.android.features.messages.media.viewer
|
||||
|
||||
import android.net.Uri
|
||||
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.local.MediaInfo
|
||||
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.FakeLocalMediaActions
|
||||
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.designsystem.utils.SnackbarDispatcher
|
||||
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
|
||||
import io.element.android.libraries.matrix.test.media.aMediaSource
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
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"
|
||||
private val TESTED_MEDIA_INFO = MediaInfo(
|
||||
name = "",
|
||||
mimeType = "",
|
||||
formattedFileSize = ""
|
||||
)
|
||||
|
||||
class MediaViewerPresenterTest {
|
||||
|
||||
private val mockMediaUrl: Uri = mockk("localMediaUri")
|
||||
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl)
|
||||
private val mediaLoader = FakeMediaLoader()
|
||||
private val mockMediaUri: Uri = mockk("localMediaUri")
|
||||
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri)
|
||||
|
||||
@Test
|
||||
fun `present - download media success scenario`() = runTest {
|
||||
val presenter = aMediaViewerPresenter()
|
||||
val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false)
|
||||
val mediaLoader = FakeMediaLoader(coroutineDispatchers)
|
||||
val mediaActions = FakeLocalMediaActions(coroutineDispatchers)
|
||||
val presenter = aMediaViewerPresenter(mediaLoader, mediaActions)
|
||||
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)
|
||||
var state = awaitItem()
|
||||
assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized)
|
||||
assertThat(state.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
|
||||
state = awaitItem()
|
||||
assertThat(state.downloadedMedia).isInstanceOf(Async.Loading::class.java)
|
||||
state = awaitItem()
|
||||
val successData = state.downloadedMedia.dataOrNull()
|
||||
assertThat(state.downloadedMedia).isInstanceOf(Async.Success::class.java)
|
||||
assertThat(successData).isNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - check all actions `() = runTest {
|
||||
val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false)
|
||||
val mediaLoader = FakeMediaLoader(coroutineDispatchers)
|
||||
val mediaActions = FakeLocalMediaActions(coroutineDispatchers)
|
||||
val presenter = aMediaViewerPresenter(mediaLoader, mediaActions)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
var state = awaitItem()
|
||||
assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized)
|
||||
state = awaitItem()
|
||||
assertThat(state.downloadedMedia).isInstanceOf(Async.Loading::class.java)
|
||||
// no state changes while media is loading
|
||||
state.eventSink(MediaViewerEvents.OpenWith)
|
||||
state.eventSink(MediaViewerEvents.Share)
|
||||
state.eventSink(MediaViewerEvents.SaveOnDisk)
|
||||
state = awaitItem()
|
||||
assertThat(state.downloadedMedia).isInstanceOf(Async.Success::class.java)
|
||||
// Should succeed without change of state
|
||||
state.eventSink(MediaViewerEvents.OpenWith)
|
||||
// Should succeed without change of state
|
||||
state.eventSink(MediaViewerEvents.Share)
|
||||
state.eventSink(MediaViewerEvents.SaveOnDisk)
|
||||
state = awaitItem()
|
||||
assertThat(state.snackbarMessage).isNotNull()
|
||||
state = awaitItem()
|
||||
assertThat(state.snackbarMessage).isNull()
|
||||
|
||||
// Check failures
|
||||
mediaActions.shouldFail = true
|
||||
state.eventSink(MediaViewerEvents.OpenWith)
|
||||
state = awaitItem()
|
||||
assertThat(state.snackbarMessage).isNotNull()
|
||||
state = awaitItem()
|
||||
assertThat(state.snackbarMessage).isNull()
|
||||
state.eventSink(MediaViewerEvents.Share)
|
||||
state = awaitItem()
|
||||
assertThat(state.snackbarMessage).isNotNull()
|
||||
state = awaitItem()
|
||||
assertThat(state.snackbarMessage).isNull()
|
||||
state.eventSink(MediaViewerEvents.SaveOnDisk)
|
||||
state = awaitItem()
|
||||
assertThat(state.snackbarMessage).isNotNull()
|
||||
state = awaitItem()
|
||||
assertThat(state.snackbarMessage).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - download media failure then retry with success scenario`() = runTest {
|
||||
val presenter = aMediaViewerPresenter()
|
||||
val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false)
|
||||
val mediaLoader = FakeMediaLoader(coroutineDispatchers)
|
||||
val mediaActions = FakeLocalMediaActions(coroutineDispatchers)
|
||||
val presenter = aMediaViewerPresenter(mediaLoader, mediaActions)
|
||||
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)
|
||||
assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
|
||||
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
|
||||
@@ -86,7 +143,6 @@ class MediaViewerPresenterTest {
|
||||
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)
|
||||
@@ -94,16 +150,20 @@ class MediaViewerPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun aMediaViewerPresenter(mimeType: String = TESTED_MIME_TYPE): MediaViewerPresenter {
|
||||
private fun aMediaViewerPresenter(
|
||||
mediaLoader: FakeMediaLoader,
|
||||
localMediaActions: FakeLocalMediaActions,
|
||||
): MediaViewerPresenter {
|
||||
return MediaViewerPresenter(
|
||||
inputs = MediaViewerNode.Inputs(
|
||||
name = TESTED_MEDIA_NAME,
|
||||
mediaInfo = TESTED_MEDIA_INFO,
|
||||
mediaSource = aMediaSource(),
|
||||
mimeType = mimeType,
|
||||
thumbnailSource = null
|
||||
),
|
||||
localMediaFactory = localMediaFactory,
|
||||
mediaLoader = mediaLoader
|
||||
mediaLoader = mediaLoader,
|
||||
localMediaActions = localMediaActions,
|
||||
snackbarDispatcher = SnackbarDispatcher()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,5 +483,5 @@ class MessageComposerPresenterTest {
|
||||
}
|
||||
|
||||
fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE)
|
||||
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, AN_EVENT_ID, A_MESSAGE)
|
||||
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE)
|
||||
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)
|
||||
|
||||
@@ -42,7 +42,8 @@ class TimelineItemGrouperTest {
|
||||
senderDisplayName = "",
|
||||
content = TimelineItemStateEventContent(body = "a state event"),
|
||||
reactionsState = TimelineItemReactions(emptyList<AggregatedReaction>().toImmutableList()),
|
||||
sendState = EventSendState.Sent(AN_EVENT_ID)
|
||||
sendState = EventSendState.Sent(AN_EVENT_ID),
|
||||
inReplyTo = null,
|
||||
)
|
||||
private val aNonGroupableItem = aMessageEvent()
|
||||
private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today"))
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.utils.messagesummary
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
|
||||
|
||||
class FakeMessageSummaryFormatter : MessageSummaryFormatter {
|
||||
|
||||
private var result = "A message"
|
||||
|
||||
override fun format(event: TimelineItem.Event): String = result
|
||||
|
||||
fun givenMessageResult(value: String) {
|
||||
result = value
|
||||
}
|
||||
}
|
||||
@@ -29,19 +29,19 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.net.toUri
|
||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.execute
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
class RoomDetailsEditPresenter @Inject constructor(
|
||||
@@ -139,13 +139,19 @@ class RoomDetailsEditPresenter @Inject constructor(
|
||||
val results = mutableListOf<Result<Unit>>()
|
||||
suspend {
|
||||
if (topic.orEmpty().trim() != room.topic.orEmpty().trim()) {
|
||||
results.add(room.setTopic(topic.orEmpty()))
|
||||
results.add(room.setTopic(topic.orEmpty()).onFailure {
|
||||
Timber.e(it, "Failed to set room topic")
|
||||
})
|
||||
}
|
||||
if (name.isNotEmpty() && name.trim() != room.name.orEmpty().trim()) {
|
||||
results.add(room.setName(name))
|
||||
results.add(room.setName(name).onFailure {
|
||||
Timber.e(it, "Failed to set room name")
|
||||
})
|
||||
}
|
||||
if (avatarUri?.toString()?.trim() != room.avatarUrl?.trim()) {
|
||||
results.add(updateAvatar(avatarUri))
|
||||
results.add(updateAvatar(avatarUri).onFailure {
|
||||
Timber.e(it, "Failed to update avatar")
|
||||
})
|
||||
}
|
||||
if (results.all { it.isSuccess }) Unit else results.first { it.isFailure }.getOrThrow()
|
||||
}.execute(action)
|
||||
@@ -153,14 +159,12 @@ class RoomDetailsEditPresenter @Inject constructor(
|
||||
|
||||
private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> {
|
||||
return runCatching {
|
||||
val result = if (avatarUri != null) {
|
||||
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow() as? MediaUploadInfo.Image
|
||||
val byteArray = preprocessed?.file?.readBytes()
|
||||
byteArray?.let { room.updateAvatar(MimeTypes.Jpeg, it) } ?: error("Could not process the given uri ($avatarUri)")
|
||||
if (avatarUri != null) {
|
||||
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
|
||||
room.updateAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
|
||||
} else {
|
||||
room.removeAvatar()
|
||||
room.removeAvatar().getOrThrow()
|
||||
}
|
||||
result.getOrThrow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ 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.libraries.matrix.ui.media.AvatarAction
|
||||
import io.element.android.features.roomdetails.aMatrixRoom
|
||||
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditEvents
|
||||
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditPresenter
|
||||
@@ -29,9 +28,9 @@ import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||
import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
@@ -600,14 +599,9 @@ class RoomDetailsEditPresenterTest {
|
||||
}
|
||||
|
||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||
fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(
|
||||
fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.AnyFile(
|
||||
file = processedFile,
|
||||
info = mockk(),
|
||||
thumbnailInfo = ThumbnailProcessingInfo(
|
||||
file = processedFile,
|
||||
info = mockk(),
|
||||
blurhash = "",
|
||||
)
|
||||
)))
|
||||
}
|
||||
|
||||
|
||||
@@ -41,17 +41,13 @@ import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -85,8 +81,8 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.noFontPadding
|
||||
import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator
|
||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.coroutines.launch
|
||||
import io.element.android.libraries.designsystem.R as DrawableR
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
@@ -184,21 +180,7 @@ fun RoomListContent(
|
||||
}
|
||||
}
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val snackbarMessageText = if (state.snackbarMessage != null) {
|
||||
stringResource(state.snackbarMessage.messageResId)
|
||||
} else null
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
if (snackbarMessageText != null) {
|
||||
SideEffect {
|
||||
coroutineScope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = snackbarMessageText,
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
|
||||
@@ -38,6 +38,9 @@ android.nonTransitiveRClass=true
|
||||
org.gradle.caching=true
|
||||
org.gradle.configureondemand=true
|
||||
org.gradle.parallel=true
|
||||
# Check here for the reasons https://github.com/square/anvil/issues/693
|
||||
# useClasspathSnapshot=false is not enough in most cases.
|
||||
kotlin.incremental=false
|
||||
|
||||
# Dummy values for signing secrets / nightly
|
||||
signing.element.nightly.storePassword=Secret
|
||||
|
||||
@@ -21,7 +21,7 @@ media3 = "1.0.2"
|
||||
browser = "1.5.0"
|
||||
|
||||
# Compose
|
||||
compose_bom = "2023.05.01"
|
||||
compose_bom = "2023.06.00"
|
||||
composecompiler = "1.4.7"
|
||||
|
||||
# Coroutines
|
||||
@@ -153,7 +153,7 @@ telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", v
|
||||
|
||||
# Analytics
|
||||
posthog = "com.posthog.android:posthog:2.0.3"
|
||||
sentry_android = "io.sentry:sentry-android:6.21.0"
|
||||
sentry_android = "io.sentry:sentry-android:6.22.0"
|
||||
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:main-SNAPSHOT"
|
||||
|
||||
# Di
|
||||
|
||||
@@ -20,17 +20,24 @@ import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import java.io.File
|
||||
import androidx.core.net.toFile
|
||||
|
||||
fun Context.getMimeType(uri: Uri): String? = when (uri.scheme) {
|
||||
ContentResolver.SCHEME_CONTENT -> contentResolver.getType(uri)
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun Context.getFileName(uri: Uri): String? = when (uri.scheme) {
|
||||
ContentResolver.SCHEME_CONTENT -> getContentFileName(uri)
|
||||
else -> uri.path?.let(::File)?.name
|
||||
ContentResolver.SCHEME_FILE -> uri.toFile().name
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun Context.getFileSize(uri: Uri): Long {
|
||||
return when (uri.scheme) {
|
||||
ContentResolver.SCHEME_CONTENT -> getContentFileSize(uri)
|
||||
else -> uri.path?.let(::File)?.length()
|
||||
ContentResolver.SCHEME_FILE -> uri.toFile().length()
|
||||
else -> 0
|
||||
} ?: 0
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ android {
|
||||
// Should not be there, but this is a POC
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.accompanist.systemui)
|
||||
implementation(libs.vanniktech.blurhash)
|
||||
implementation(projects.libraries.elementresources)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.blurhash
|
||||
package io.element.android.libraries.designsystem.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Used to create a column where all children have the same width.
|
||||
* It will first measure all children, get the largest width and re-measure all children with this width as the minWidth.
|
||||
*
|
||||
* *Note*: If all children already have the same width, it skips the 2nd measuring and acts like a normal Column.
|
||||
*/
|
||||
@Composable
|
||||
fun EqualWidthColumn(
|
||||
modifier: Modifier = Modifier,
|
||||
spacing: Dp = 0.dp,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
SubcomposeLayout(modifier = modifier) { constraints ->
|
||||
val measurables = subcompose(0, content).map { it.measure(constraints) }
|
||||
val maxWidth = measurables.maxOf { it.width }
|
||||
val newConstraints = constraints.copy(minWidth = maxWidth)
|
||||
val newMeasurables = if (measurables.all { it.width == maxWidth }) {
|
||||
// Skip re-measuring if all children have the same width
|
||||
measurables
|
||||
} else {
|
||||
// Re-measure with the largest width as the minWidth to have all children constrained to the same width
|
||||
subcompose(1, content).map { it.measure(newConstraints) }
|
||||
}
|
||||
val totalHeight = (newMeasurables.sumOf { it.height } + spacing.toPx() * (newMeasurables.size - 1)).roundToInt()
|
||||
layout(maxWidth, totalHeight) {
|
||||
var yPosition = 0
|
||||
newMeasurables.forEach { measurable ->
|
||||
measurable.placeRelative(0, yPosition)
|
||||
yPosition += measurable.height + spacing.roundToPx()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ fun Text(
|
||||
lineHeight: TextUnit = TextUnit.Unspecified,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
softWrap: Boolean = true,
|
||||
minLines: Int = 1,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
style: TextStyle = LocalTextStyle.current
|
||||
@@ -84,6 +85,7 @@ fun Text(
|
||||
lineHeight = lineHeight,
|
||||
overflow = overflow,
|
||||
softWrap = softWrap,
|
||||
minLines = minLines,
|
||||
maxLines = maxLines,
|
||||
onTextLayout = onTextLayout,
|
||||
style = style,
|
||||
@@ -105,6 +107,7 @@ fun Text(
|
||||
lineHeight: TextUnit = TextUnit.Unspecified,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
softWrap: Boolean = true,
|
||||
minLines: Int = 1,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
inlineContent: ImmutableMap<String, InlineTextContent> = persistentMapOf(),
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
@@ -124,6 +127,7 @@ fun Text(
|
||||
lineHeight = lineHeight,
|
||||
overflow = overflow,
|
||||
softWrap = softWrap,
|
||||
minLines = minLines,
|
||||
maxLines = maxLines,
|
||||
inlineContent = inlineContent,
|
||||
onTextLayout = onTextLayout,
|
||||
|
||||
@@ -18,11 +18,14 @@ package io.element.android.libraries.designsystem.utils
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
@@ -56,7 +59,7 @@ fun handleSnackbarMessage(
|
||||
val snackbarMessage by snackbarDispatcher.snackbarMessage.collectAsState(initial = null)
|
||||
LaunchedEffect(snackbarMessage) {
|
||||
if (snackbarMessage != null) {
|
||||
launch(Dispatchers.Main) {
|
||||
launch {
|
||||
snackbarDispatcher.clear()
|
||||
}
|
||||
}
|
||||
@@ -64,6 +67,25 @@ fun handleSnackbarMessage(
|
||||
return snackbarMessage
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostState {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val snackbarMessageText = snackbarMessage?.let {
|
||||
stringResource(id = snackbarMessage.messageResId)
|
||||
}
|
||||
LaunchedEffect(snackbarMessage) {
|
||||
if (snackbarMessageText == null) return@LaunchedEffect
|
||||
coroutineScope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = snackbarMessageText,
|
||||
duration = snackbarMessage.duration,
|
||||
)
|
||||
}
|
||||
}
|
||||
return snackbarHostState
|
||||
}
|
||||
|
||||
data class SnackbarMessage(
|
||||
@StringRes val messageResId: Int,
|
||||
val duration: SnackbarDuration = SnackbarDuration.Short,
|
||||
@@ -33,8 +33,9 @@ interface MatrixMediaLoader {
|
||||
|
||||
/**
|
||||
* @param source to fetch the data for.
|
||||
* @param mimeType: optional mime type
|
||||
* @param mimeType: optional mime type.
|
||||
* @param body: optional body which will be used to name the file.
|
||||
* @return a [Result] of [MediaFile]
|
||||
*/
|
||||
suspend fun downloadMediaFile(source: MediaSource, mimeType: String?): Result<MediaFile>
|
||||
suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result<MediaFile>
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package io.element.android.libraries.matrix.api.media
|
||||
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A wrapper around a media file on the disk.
|
||||
@@ -25,3 +26,7 @@ import java.io.Closeable
|
||||
interface MediaFile : Closeable {
|
||||
fun path(): String
|
||||
}
|
||||
|
||||
fun MediaFile.toFile(): File {
|
||||
return File(path())
|
||||
}
|
||||
|
||||
@@ -28,11 +28,25 @@ sealed interface EventContent
|
||||
|
||||
data class MessageContent(
|
||||
val body: String,
|
||||
val inReplyTo: EventId?,
|
||||
val inReplyTo: InReplyTo?,
|
||||
val isEdited: Boolean,
|
||||
val type: MessageType?
|
||||
) : EventContent
|
||||
|
||||
|
||||
sealed interface InReplyTo {
|
||||
data class NotLoaded(val eventId: EventId) : InReplyTo
|
||||
data class Ready(
|
||||
val eventId: EventId,
|
||||
val content: MessageContent,
|
||||
val senderId: UserId,
|
||||
val senderDisplayName: String?,
|
||||
val senderAvatarUrl: String?,
|
||||
) : InReplyTo
|
||||
|
||||
object Error : InReplyTo
|
||||
}
|
||||
|
||||
object RedactedContent : EventContent
|
||||
|
||||
data class StickerContent(
|
||||
|
||||
@@ -32,4 +32,12 @@ data class EventTimelineItem(
|
||||
val senderProfile: ProfileTimelineDetails,
|
||||
val timestamp: Long,
|
||||
val content: EventContent
|
||||
)
|
||||
) {
|
||||
fun inReplyTo(): InReplyTo? {
|
||||
return (content as? MessageContent)?.inReplyTo
|
||||
}
|
||||
fun hasNotLoadedInReplyTo(): Boolean {
|
||||
val details = inReplyTo()
|
||||
return details is InReplyTo.NotLoaded || details is InReplyTo.Error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ class RustMatrixClient constructor(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val baseDirectory: File,
|
||||
private val baseCacheDirectory: File,
|
||||
private val clock: SystemClock,
|
||||
) : MatrixClient {
|
||||
|
||||
@@ -188,7 +189,7 @@ class RustMatrixClient constructor(
|
||||
override val invitesDataSource: RoomSummaryDataSource
|
||||
get() = rustInvitesDataSource
|
||||
|
||||
private val rustMediaLoader = RustMediaLoader(dispatchers, client)
|
||||
private val rustMediaLoader = RustMediaLoader(baseCacheDirectory, dispatchers, client)
|
||||
override val mediaLoader: MatrixMediaLoader
|
||||
get() = rustMediaLoader
|
||||
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
|
||||
package io.element.android.libraries.matrix.impl.auth
|
||||
|
||||
import android.content.Context
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.extensions.mapFailure
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
@@ -48,6 +50,7 @@ import org.matrix.rustcomponents.sdk.AuthenticationService as RustAuthentication
|
||||
@ContributesBinding(AppScope::class)
|
||||
@SingleIn(AppScope::class)
|
||||
class RustMatrixAuthenticationService @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val baseDirectory: File,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
@@ -179,6 +182,7 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||
coroutineScope = coroutineScope,
|
||||
dispatchers = coroutineDispatchers,
|
||||
baseDirectory = baseDirectory,
|
||||
baseCacheDirectory = context.cacheDir,
|
||||
clock = clock,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,13 +24,21 @@ import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.mediaSourceFromUrl
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import java.io.File
|
||||
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
|
||||
|
||||
class RustMediaLoader(
|
||||
baseCacheDirectory: File,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val innerClient: Client
|
||||
private val innerClient: Client,
|
||||
) : MatrixMediaLoader {
|
||||
|
||||
private val cacheDirectory = File(baseCacheDirectory, "temp/media").apply {
|
||||
if (!exists()) {
|
||||
mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUnsignedTypes::class)
|
||||
override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> =
|
||||
withContext(dispatchers.io) {
|
||||
@@ -59,14 +67,16 @@ class RustMediaLoader(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?): Result<MediaFile> =
|
||||
override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result<MediaFile> =
|
||||
withContext(dispatchers.io) {
|
||||
runCatching {
|
||||
source.toRustMediaSource().use { mediaSource ->
|
||||
val mediaFile = innerClient.getMediaFile(
|
||||
mediaSource = mediaSource,
|
||||
body = null,
|
||||
mimeType = mimeType ?: "application/octet-stream"
|
||||
body = body,
|
||||
mimeType = mimeType ?: "application/octet-stream",
|
||||
//TODO uncomment when rust api will be merged
|
||||
//tempDir = cacheDirectory.path,
|
||||
)
|
||||
RustMediaFile(mediaFile)
|
||||
}
|
||||
|
||||
@@ -16,12 +16,19 @@
|
||||
|
||||
package io.element.android.libraries.matrix.impl.timeline
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.matrix.rustcomponents.sdk.Room
|
||||
import org.matrix.rustcomponents.sdk.TimelineItem
|
||||
import timber.log.Timber
|
||||
|
||||
class MatrixTimelineItemMapper(
|
||||
private val room: Room,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val virtualTimelineItemMapper: VirtualTimelineItemMapper = VirtualTimelineItemMapper(),
|
||||
private val eventTimelineItemMapper: EventTimelineItemMapper= EventTimelineItemMapper(),
|
||||
) {
|
||||
@@ -30,6 +37,12 @@ class MatrixTimelineItemMapper(
|
||||
val asEvent = it.asEvent()
|
||||
if (asEvent != null) {
|
||||
val eventTimelineItem = eventTimelineItemMapper.map(asEvent)
|
||||
|
||||
|
||||
if (eventTimelineItem.hasNotLoadedInReplyTo() && eventTimelineItem.eventId != null) {
|
||||
fetchDetailsForEvent(eventTimelineItem.eventId!!)
|
||||
}
|
||||
|
||||
return MatrixTimelineItem.Event(eventTimelineItem)
|
||||
}
|
||||
val asVirtual = it.asVirtual()
|
||||
@@ -39,4 +52,13 @@ class MatrixTimelineItemMapper(
|
||||
}
|
||||
return MatrixTimelineItem.Other
|
||||
}
|
||||
|
||||
private fun fetchDetailsForEvent(eventId: EventId) = coroutineScope.launch {
|
||||
runCatching {
|
||||
room.fetchDetailsForEvent(eventId.value)
|
||||
}.onFailure {
|
||||
Timber.e(it)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ class RustMatrixTimeline(
|
||||
)
|
||||
|
||||
private val timelineItemFactory = MatrixTimelineItemMapper(
|
||||
room = innerRoom,
|
||||
coroutineScope = coroutineScope,
|
||||
virtualTimelineItemMapper = VirtualTimelineItemMapper(),
|
||||
eventTimelineItemMapper = EventTimelineItemMapper(
|
||||
contentMapper = TimelineEventContentMapper(
|
||||
|
||||
@@ -17,11 +17,13 @@
|
||||
package io.element.android.libraries.matrix.impl.timeline.item.event
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.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.FileMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
||||
@@ -31,6 +33,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT
|
||||
import io.element.android.libraries.matrix.impl.media.map
|
||||
import org.matrix.rustcomponents.sdk.Message
|
||||
import org.matrix.rustcomponents.sdk.MessageType
|
||||
import org.matrix.rustcomponents.sdk.ProfileDetails
|
||||
import org.matrix.rustcomponents.sdk.RepliedToEventDetails
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody
|
||||
import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat
|
||||
@@ -66,9 +70,26 @@ class EventMessageMapper {
|
||||
}
|
||||
}
|
||||
}
|
||||
val inReplyToId = it.inReplyTo()?.eventId?.let(::EventId)
|
||||
val inReplyToEvent: InReplyTo? = (it.inReplyTo()?.event)?.use { details ->
|
||||
when (details) {
|
||||
is RepliedToEventDetails.Ready -> {
|
||||
val senderProfile = details.senderProfile as? ProfileDetails.Ready
|
||||
InReplyTo.Ready(
|
||||
eventId = inReplyToId!!,
|
||||
content = map(details.message),
|
||||
senderId = UserId(details.sender),
|
||||
senderDisplayName = senderProfile?.displayName,
|
||||
senderAvatarUrl = senderProfile?.avatarUrl,
|
||||
)
|
||||
}
|
||||
is RepliedToEventDetails.Error -> InReplyTo.Error
|
||||
is RepliedToEventDetails.Pending, is RepliedToEventDetails.Unavailable -> InReplyTo.NotLoaded(inReplyToId!!)
|
||||
}
|
||||
}
|
||||
MessageContent(
|
||||
body = it.body(),
|
||||
inReplyTo = it.inReplyTo()?.eventId?.let(::EventId),
|
||||
inReplyTo = inReplyToEvent,
|
||||
isEdited = it.isEdited(),
|
||||
type = type
|
||||
)
|
||||
|
||||
@@ -26,4 +26,6 @@ dependencies {
|
||||
api(projects.libraries.core)
|
||||
api(projects.libraries.matrix.api)
|
||||
api(libs.coroutines.core)
|
||||
implementation(libs.coroutines.test)
|
||||
implementation(projects.tests.testutils)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package io.element.android.libraries.matrix.test
|
||||
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
@@ -36,15 +37,18 @@ 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.FakeRoomSummaryDataSource
|
||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
|
||||
class FakeMatrixClient(
|
||||
override val sessionId: SessionId = A_SESSION_ID,
|
||||
private val coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||
private val userDisplayName: Result<String> = Result.success(A_USER_NAME),
|
||||
private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL),
|
||||
override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
|
||||
override val invitesDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
|
||||
override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(),
|
||||
override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(coroutineDispatchers),
|
||||
private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
|
||||
private val pushersService: FakePushersService = FakePushersService(),
|
||||
private val notificationService: FakeNotificationService = FakeNotificationService(),
|
||||
|
||||
@@ -16,40 +16,40 @@
|
||||
|
||||
package io.element.android.libraries.matrix.test.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 io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
class FakeMediaLoader : MatrixMediaLoader {
|
||||
class FakeMediaLoader(private val coroutineDispatchers: CoroutineDispatchers) : MatrixMediaLoader {
|
||||
|
||||
var shouldFail = false
|
||||
|
||||
override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> {
|
||||
delay(FAKE_DELAY_IN_MS)
|
||||
return if (shouldFail) {
|
||||
override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> = withContext(coroutineDispatchers.io){
|
||||
if (shouldFail) {
|
||||
Result.failure(RuntimeException())
|
||||
} else {
|
||||
return Result.success(ByteArray(0))
|
||||
Result.success(ByteArray(0))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result<ByteArray> {
|
||||
delay(FAKE_DELAY_IN_MS)
|
||||
return if (shouldFail) {
|
||||
override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result<ByteArray> = withContext(coroutineDispatchers.io){
|
||||
if (shouldFail) {
|
||||
Result.failure(RuntimeException())
|
||||
} else {
|
||||
return Result.success(ByteArray(0))
|
||||
Result.success(ByteArray(0))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?): Result<MediaFile> {
|
||||
delay(FAKE_DELAY_IN_MS)
|
||||
return if (shouldFail) {
|
||||
override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result<MediaFile> = withContext(coroutineDispatchers.io){
|
||||
if (shouldFail) {
|
||||
Result.failure(RuntimeException())
|
||||
} else {
|
||||
return Result.success(FakeMediaFile(""))
|
||||
Result.success(FakeMediaFile(""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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.ui.components
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Attachment
|
||||
import androidx.compose.material.icons.outlined.VideoCameraBack
|
||||
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.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Composable
|
||||
fun AttachmentThumbnail(
|
||||
info: AttachmentThumbnailInfo,
|
||||
modifier: Modifier = Modifier,
|
||||
thumbnailSize: Long = 32L,
|
||||
backgroundColor: Color = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
if (info.mediaSource != null) {
|
||||
val mediaRequestData = MediaRequestData(
|
||||
source = info.mediaSource,
|
||||
kind = MediaRequestData.Kind.Thumbnail(thumbnailSize),
|
||||
)
|
||||
BlurHashAsyncImage(
|
||||
model = mediaRequestData,
|
||||
blurHash = info.blurHash,
|
||||
contentDescription = info.textContent,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = modifier.background(backgroundColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (info.type) {
|
||||
AttachmentThumbnailType.Video -> {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.VideoCameraBack,
|
||||
contentDescription = info.textContent,
|
||||
)
|
||||
}
|
||||
AttachmentThumbnailType.File -> {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Attachment,
|
||||
contentDescription = info.textContent,
|
||||
modifier = Modifier.rotate(-45f)
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
enum class AttachmentThumbnailType: Parcelable {
|
||||
Image, Video, File
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class AttachmentThumbnailInfo(
|
||||
val mediaSource: MediaSource?,
|
||||
val textContent: String?,
|
||||
val type: AttachmentThumbnailType?,
|
||||
val blurHash: String?,
|
||||
): Parcelable
|
||||
@@ -19,7 +19,7 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.matrix.test"
|
||||
namespace = "io.element.android.libraries.mediaupload.test"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
@@ -32,6 +32,6 @@ class RetrofitFactory @Inject constructor(
|
||||
fun create(baseUrl: String): Retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseUrl.ensureTrailingSlash())
|
||||
.addConverterFactory(json.get().asConverterFactory("application/json".toMediaType()))
|
||||
.callFactory(okHttpClient.get())
|
||||
.callFactory { request -> okHttpClient.get().newCall(request) }
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<string name="notification_inline_reply_failed">"** Failed to send - please open room"</string>
|
||||
<string name="notification_invitation_action_join">"Join"</string>
|
||||
<string name="notification_invitation_action_reject">"Reject"</string>
|
||||
<string name="notification_invite_body">"invited you"</string>
|
||||
<string name="notification_new_messages">"New Messages"</string>
|
||||
<string name="notification_room_action_mark_as_read">"Mark as read"</string>
|
||||
<string name="notification_sender_me">"Me"</string>
|
||||
|
||||
@@ -22,9 +22,6 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.textcomposer"
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -33,9 +30,7 @@ dependencies {
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(libs.wysiwyg)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.androidx.material)
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<manifest>
|
||||
|
||||
</manifest>
|
||||
@@ -18,6 +18,7 @@ package io.element.android.libraries.textcomposer
|
||||
|
||||
import android.os.Parcelable
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
sealed interface MessageComposerMode : Parcelable {
|
||||
@@ -38,6 +39,7 @@ sealed interface MessageComposerMode : Parcelable {
|
||||
@Parcelize
|
||||
class Reply(
|
||||
val senderName: String,
|
||||
val attachmentThumbnailInfo: AttachmentThumbnailInfo?,
|
||||
override val eventId: EventId,
|
||||
override val defaultContent: CharSequence
|
||||
) : Special(eventId, defaultContent)
|
||||
|
||||
@@ -59,8 +59,10 @@ import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||
@@ -73,6 +75,10 @@ import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -180,21 +186,99 @@ private fun ComposerModeView(
|
||||
) {
|
||||
when (composerMode) {
|
||||
is MessageComposerMode.Edit -> {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Icon(
|
||||
resourceId = VectorIcons.Edit,
|
||||
contentDescription = stringResource(R.string.editing),
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
EditingModeView(onResetComposerMode = onResetComposerMode, modifier = modifier)
|
||||
}
|
||||
is MessageComposerMode.Reply -> {
|
||||
ReplyToModeView(
|
||||
modifier = modifier.padding(8.dp),
|
||||
senderName = composerMode.senderName,
|
||||
text = composerMode.defaultContent.toString(),
|
||||
attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo,
|
||||
onResetComposerMode = onResetComposerMode,
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditingModeView(
|
||||
onResetComposerMode: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Icon(
|
||||
resourceId = VectorIcons.Edit,
|
||||
contentDescription = stringResource(R.string.editing),
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.editing),
|
||||
style = ElementTextStyles.Regular.caption2,
|
||||
textAlign = TextAlign.Start,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(StringR.string.action_close),
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clickable(
|
||||
enabled = true,
|
||||
onClick = onResetComposerMode,
|
||||
interactionSource = MutableInteractionSource(),
|
||||
indication = rememberRipple(bounded = false)
|
||||
),
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyToModeView(
|
||||
senderName: String,
|
||||
text: String?,
|
||||
attachmentThumbnailInfo: AttachmentThumbnailInfo?,
|
||||
onResetComposerMode: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val paddings = if (attachmentThumbnailInfo != null) {
|
||||
PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp)
|
||||
} else {
|
||||
PaddingValues(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp)
|
||||
}
|
||||
Row(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(13.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(paddings)
|
||||
) {
|
||||
if (attachmentThumbnailInfo != null) {
|
||||
AttachmentThumbnail(
|
||||
info = attachmentThumbnailInfo,
|
||||
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(9.dp))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.SpaceEvenly) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.editing),
|
||||
style = ElementTextStyles.Regular.caption2,
|
||||
senderName,
|
||||
style = ElementTextStyles.Regular.caption2.copy(fontWeight = FontWeight.Medium),
|
||||
textAlign = TextAlign.Start,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
@@ -209,11 +293,19 @@ private fun ComposerModeView(
|
||||
interactionSource = MutableInteractionSource(),
|
||||
indication = rememberRipple(bounded = false)
|
||||
),
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = text.orEmpty(),
|
||||
style = ElementTextStyles.Regular.caption1,
|
||||
textAlign = TextAlign.Start,
|
||||
color = LocalColors.current.placeholder,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,14 +381,30 @@ private fun BoxScope.SendButton(
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun TextComposerLightPreview() = ElementPreviewLight { ContentToPreview() }
|
||||
internal fun TextComposerSimpleLightPreview() = ElementPreviewLight { SimpleContentToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun TextComposerDarkPreview() = ElementPreviewDark { ContentToPreview() }
|
||||
internal fun TextComposerSimpleDarkPreview() = ElementPreviewDark { SimpleContentToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun TextComposerEditLightPreview() = ElementPreviewLight { EditContentToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun TextComposerEditDarkPreview() = ElementPreviewDark { EditContentToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun TextComposerReplyLightPreview() = ElementPreviewLight { ReplyContentToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun TextComposerReplyDarkPreview() = ElementPreviewDark { ReplyContentToPreview() }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview() {
|
||||
private fun SimpleContentToPreview() {
|
||||
Column {
|
||||
TextComposer(
|
||||
onSendMessage = {},
|
||||
@@ -322,10 +430,89 @@ private fun ContentToPreview() {
|
||||
composerCanSendMessage = true,
|
||||
composerText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditContentToPreview() {
|
||||
TextComposer(
|
||||
onSendMessage = {},
|
||||
onComposerTextChange = {},
|
||||
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text"),
|
||||
onResetComposerMode = {},
|
||||
composerCanSendMessage = true,
|
||||
composerText = "A message",
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyContentToPreview() {
|
||||
Column {
|
||||
TextComposer(
|
||||
onSendMessage = {},
|
||||
onComposerTextChange = {},
|
||||
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text"),
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
senderName = "Alice",
|
||||
eventId = EventId("$1234"),
|
||||
attachmentThumbnailInfo = null,
|
||||
defaultContent = "A message\n" +
|
||||
"With several lines\n" +
|
||||
"To preview larger textfields and long lines with overflow"
|
||||
),
|
||||
onResetComposerMode = {},
|
||||
composerCanSendMessage = true,
|
||||
composerText = "A message",
|
||||
)
|
||||
TextComposer(
|
||||
onSendMessage = {},
|
||||
onComposerTextChange = {},
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
senderName = "Alice",
|
||||
eventId = EventId("$1234"),
|
||||
attachmentThumbnailInfo = AttachmentThumbnailInfo(
|
||||
mediaSource = MediaSource("https://domain.com/image.jpg"),
|
||||
textContent = "image.jpg",
|
||||
type = AttachmentThumbnailType.Image,
|
||||
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
|
||||
),
|
||||
defaultContent = "image.jpg"
|
||||
),
|
||||
onResetComposerMode = {},
|
||||
composerCanSendMessage = true,
|
||||
composerText = "A message",
|
||||
)
|
||||
TextComposer(
|
||||
onSendMessage = {},
|
||||
onComposerTextChange = {},
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
senderName = "Alice",
|
||||
eventId = EventId("$1234"),
|
||||
attachmentThumbnailInfo = AttachmentThumbnailInfo(
|
||||
mediaSource = MediaSource("https://domain.com/video.mp4"),
|
||||
textContent = "video.mp4",
|
||||
type = AttachmentThumbnailType.Video,
|
||||
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
|
||||
),
|
||||
defaultContent = "video.mp4"
|
||||
),
|
||||
onResetComposerMode = {},
|
||||
composerCanSendMessage = true,
|
||||
composerText = "A message",
|
||||
)
|
||||
TextComposer(
|
||||
onSendMessage = {},
|
||||
onComposerTextChange = {},
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
senderName = "Alice",
|
||||
eventId = EventId("$1234"),
|
||||
attachmentThumbnailInfo = AttachmentThumbnailInfo(
|
||||
mediaSource = null,
|
||||
textContent = "logs.txt",
|
||||
type = AttachmentThumbnailType.File,
|
||||
blurHash = null,
|
||||
),
|
||||
defaultContent = "logs.txt"
|
||||
),
|
||||
onResetComposerMode = {},
|
||||
composerCanSendMessage = true,
|
||||
composerText = "A message",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_hovered="true">
|
||||
<shape android:shape="rectangle">
|
||||
<corners android:radius="8dp" />
|
||||
<solid android:color="?attr/vctr_rich_text_editor_menu_button_background" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_selected="true">
|
||||
<shape android:shape="rectangle">
|
||||
<corners android:radius="8dp" />
|
||||
<solid android:color="?attr/vctr_rich_text_editor_menu_button_background" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<ripple android:color="?attr/vctr_rich_text_editor_menu_button_background" />
|
||||
</item>
|
||||
</selector>
|
||||
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="?vctr_content_quinary" />
|
||||
<corners android:radius="4dp" />
|
||||
|
||||
</shape>
|
||||
@@ -1,26 +0,0 @@
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="44dp"
|
||||
android:height="44dp"
|
||||
android:viewportWidth="44"
|
||||
android:viewportHeight="44">
|
||||
<path
|
||||
android:pathData="M16,14.5C16,13.672 16.672,13 17.5,13H22.288C25.139,13 27.25,15.466 27.25,18.25C27.25,19.38 26.902,20.458 26.298,21.34C27.765,22.268 28.75,23.882 28.75,25.75C28.75,28.689 26.311,31 23.393,31H17.5C16.672,31 16,30.328 16,29.5V14.5ZM19,16V20.5H22.288C23.261,20.5 24.25,19.608 24.25,18.25C24.25,16.892 23.261,16 22.288,16H19ZM19,23.5V28H23.393C24.735,28 25.75,26.953 25.75,25.75C25.75,24.547 24.735,23.5 23.393,23.5H19Z"
|
||||
android:fillColor="#8D97A5"
|
||||
android:fillType="evenOdd" />
|
||||
</vector>
|
||||
@@ -1,25 +0,0 @@
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path
|
||||
android:fillColor="#C1C6CD"
|
||||
android:pathData="M10.708,10Q10.438,10 10.219,9.781Q10,9.562 10,9.292V4.542Q10,4.354 10.146,4.219Q10.292,4.083 10.458,4.083Q10.646,4.083 10.781,4.219Q10.917,4.354 10.917,4.542V8.438L16.375,3Q16.5,2.854 16.688,2.854Q16.875,2.854 17,3Q17.146,3.125 17.146,3.312Q17.146,3.5 17,3.625L11.562,9.083H15.458Q15.646,9.083 15.781,9.229Q15.917,9.375 15.917,9.542Q15.917,9.729 15.781,9.865Q15.646,10 15.458,10ZM3,17Q2.854,16.875 2.854,16.688Q2.854,16.5 3,16.375L8.438,10.917H4.542Q4.354,10.917 4.219,10.771Q4.083,10.625 4.083,10.458Q4.083,10.271 4.219,10.135Q4.354,10 4.542,10H9.292Q9.562,10 9.781,10.219Q10,10.438 10,10.708V15.458Q10,15.646 9.854,15.781Q9.708,15.917 9.542,15.917Q9.354,15.917 9.219,15.781Q9.083,15.646 9.083,15.458V11.562L3.625,17Q3.5,17.146 3.312,17.146Q3.125,17.146 3,17Z" />
|
||||
</vector>
|
||||
@@ -1,25 +0,0 @@
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:pathData="M17.125,31.5C16.944,31.5 16.795,31.441 16.677,31.323C16.559,31.205 16.5,31.056 16.5,30.875V25.875C16.5,25.694 16.559,25.545 16.677,25.427C16.795,25.309 16.944,25.25 17.125,25.25C17.306,25.25 17.455,25.309 17.573,25.427C17.691,25.545 17.75,25.694 17.75,25.875V29.375L29.375,17.75H25.875C25.694,17.75 25.545,17.691 25.427,17.573C25.309,17.455 25.25,17.306 25.25,17.125C25.25,16.944 25.309,16.795 25.427,16.677C25.545,16.559 25.694,16.5 25.875,16.5H30.875C31.056,16.5 31.205,16.559 31.323,16.677C31.441,16.795 31.5,16.944 31.5,17.125V22.125C31.5,22.306 31.441,22.455 31.323,22.573C31.205,22.691 31.056,22.75 30.875,22.75C30.694,22.75 30.545,22.691 30.427,22.573C30.309,22.455 30.25,22.306 30.25,22.125V18.625L18.625,30.25H22.125C22.306,30.25 22.455,30.309 22.573,30.427C22.691,30.545 22.75,30.694 22.75,30.875C22.75,31.056 22.691,31.205 22.573,31.323C22.455,31.441 22.306,31.5 22.125,31.5H17.125Z"
|
||||
android:fillColor="#C1C6CD" />
|
||||
</vector>
|
||||
@@ -1,26 +0,0 @@
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="44dp"
|
||||
android:height="44dp"
|
||||
android:viewportWidth="44"
|
||||
android:viewportHeight="44">
|
||||
<path
|
||||
android:pathData="M22.619,14.999L19.747,29.005H17.2C16.758,29.005 16.4,29.363 16.4,29.805C16.4,30.247 16.758,30.605 17.2,30.605H20.389C20.397,30.605 20.405,30.605 20.412,30.605H23.6C24.042,30.605 24.4,30.247 24.4,29.805C24.4,29.363 24.042,29.005 23.6,29.005H21.381L24.253,14.999H26.8C27.242,14.999 27.6,14.64 27.6,14.199C27.6,13.757 27.242,13.399 26.8,13.399H23.615C23.604,13.398 23.594,13.398 23.583,13.399H20.4C19.958,13.399 19.6,13.757 19.6,14.199C19.6,14.64 19.958,14.999 20.4,14.999H22.619Z"
|
||||
android:fillColor="#8D97A5"
|
||||
android:fillType="evenOdd" />
|
||||
</vector>
|
||||
@@ -1,25 +0,0 @@
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="12dp"
|
||||
android:height="12dp"
|
||||
android:viewportWidth="12"
|
||||
android:viewportHeight="12">
|
||||
<path
|
||||
android:pathData="M10.403,2.53C10.696,2.237 10.696,1.763 10.403,1.47C10.111,1.177 9.636,1.177 9.343,1.47L5.946,4.867L2.549,1.47C2.256,1.177 1.781,1.177 1.488,1.47C1.195,1.763 1.195,2.237 1.488,2.53L4.885,5.927L1.343,9.47C1.05,9.763 1.05,10.237 1.343,10.53C1.636,10.823 2.11,10.823 2.403,10.53L5.946,6.988L9.488,10.53C9.781,10.823 10.256,10.823 10.549,10.53C10.842,10.237 10.842,9.763 10.549,9.47L7.006,5.927L10.403,2.53Z"
|
||||
android:fillColor="#8D97A5" />
|
||||
</vector>
|
||||
@@ -1,28 +0,0 @@
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="12dp"
|
||||
android:height="12dp"
|
||||
android:viewportWidth="12"
|
||||
android:viewportHeight="12">
|
||||
<path
|
||||
android:pathData="M2.649,7.355C2.655,7.316 2.672,7.28 2.699,7.251L8.404,1.064C8.479,0.983 8.605,0.978 8.686,1.053L9.863,2.138C9.944,2.213 9.949,2.339 9.874,2.42L4.169,8.607C4.143,8.636 4.108,8.656 4.069,8.665L2.668,9.005C2.529,9.039 2.401,8.92 2.423,8.779L2.649,7.355Z"
|
||||
android:fillColor="#8D97A5" />
|
||||
<path
|
||||
android:pathData="M1.75,9.443C1.336,9.443 1,9.779 1,10.193C1,10.608 1.336,10.943 1.75,10.943L10.75,10.943C11.164,10.943 11.5,10.608 11.5,10.193C11.5,9.779 11.164,9.443 10.75,9.443L1.75,9.443Z"
|
||||
android:fillColor="#8D97A5" />
|
||||
</vector>
|
||||
@@ -1,32 +0,0 @@
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="36dp"
|
||||
android:height="36dp"
|
||||
android:viewportWidth="36"
|
||||
android:viewportHeight="36">
|
||||
<path
|
||||
android:pathData="M18,18m-18,0a18,18 0,1 1,36 0a18,18 0,1 1,-36 0"
|
||||
android:fillColor="#0DBD8B" />
|
||||
<path
|
||||
android:pathData="M9.818,18.787L14.705,23.818L26.182,12"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2.5"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#ffffff"
|
||||
android:strokeLineCap="round" />
|
||||
</vector>
|
||||
@@ -1,28 +0,0 @@
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="44dp"
|
||||
android:height="44dp"
|
||||
android:viewportWidth="44"
|
||||
android:viewportHeight="44">
|
||||
<path
|
||||
android:pathData="M24.897,17.154C24.235,15.821 22.876,15.21 21.374,15.372C19.05,15.622 18.44,17.423 18.722,18.592C19.032,19.872 20.046,20.37 21.839,20.826H29.92C30.517,20.826 31,21.351 31,22C31,22.648 30.517,23.174 29.92,23.174H14.08C13.483,23.174 13,22.648 13,22C13,21.351 13.483,20.826 14.08,20.826H17.355C17.041,20.377 16.791,19.839 16.633,19.189C16.003,16.581 17.554,13.424 21.16,13.036C23.285,12.807 25.615,13.661 26.798,16.038C27.081,16.608 26.886,17.32 26.361,17.629C25.836,17.937 25.181,17.725 24.897,17.154Z"
|
||||
android:fillColor="#8D97A5" />
|
||||
<path
|
||||
android:pathData="M25.427,25.13H27.67C27.888,26.306 27.721,27.56 27.05,28.632C26.114,30.125 24.37,31 21.985,31C18.076,31 16.279,28.584 15.912,26.986C15.768,26.357 16.12,25.72 16.698,25.563C17.277,25.406 17.863,25.788 18.008,26.417C18.119,26.902 19.002,28.652 21.985,28.652C23.907,28.652 24.854,27.965 25.264,27.31C25.642,26.707 25.708,25.909 25.427,25.13Z"
|
||||
android:fillColor="#8D97A5" />
|
||||
</vector>
|
||||
@@ -1,28 +0,0 @@
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="44dp"
|
||||
android:height="44dp"
|
||||
android:viewportWidth="44"
|
||||
android:viewportHeight="44">
|
||||
<group>
|
||||
<clip-path android:pathData="M10,10h24v24h-24z" />
|
||||
<path
|
||||
android:pathData="M22.79,26.95C25.82,26.56 28,23.84 28,20.79V14.25C28,13.56 27.44,13 26.75,13C26.06,13 25.5,13.56 25.5,14.25V20.9C25.5,22.57 24.37,24.09 22.73,24.42C20.48,24.89 18.5,23.17 18.5,21V14.25C18.5,13.56 17.94,13 17.25,13C16.56,13 16,13.56 16,14.25V21C16,24.57 19.13,27.42 22.79,26.95ZM15,30C15,30.55 15.45,31 16,31H28C28.55,31 29,30.55 29,30C29,29.45 28.55,29 28,29H16C15.45,29 15,29.45 15,30Z"
|
||||
android:fillColor="#8D97A5" />
|
||||
</group>
|
||||
</vector>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user