Merge branch 'develop' into feature/fga/update-rust-sdk-0.1.16

This commit is contained in:
ganfra 2023-06-08 16:21:49 +02:00
commit 669ff1fe2f
137 changed files with 2382 additions and 1391 deletions

View file

@ -17,6 +17,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> 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 <application
android:name=".ElementXApplication" android:name=".ElementXApplication"
android:allowBackup="true" android:allowBackup="true"

View file

@ -246,6 +246,7 @@ koverMerged {
excludes += "io.element.android.libraries.matrix.api.room.MatrixRoomMembersState*" excludes += "io.element.android.libraries.matrix.api.room.MatrixRoomMembersState*"
excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*" 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.pdf.PdfViewerState"
excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
} }
bound { bound {
minValue = 90 minValue = 90

View file

@ -32,6 +32,7 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode 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.media.viewer.MediaViewerNode
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@ -64,10 +65,9 @@ class MessagesFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data class MediaViewer( data class MediaViewer(
val title: String, val mediaInfo: MediaInfo,
val mediaSource: MediaSource, val mediaSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val mimeType: String?,
) : NavTarget ) : NavTarget
@Parcelize @Parcelize
@ -100,10 +100,9 @@ class MessagesFlowNode @AssistedInject constructor(
} }
is NavTarget.MediaViewer -> { is NavTarget.MediaViewer -> {
val inputs = MediaViewerNode.Inputs( val inputs = MediaViewerNode.Inputs(
name = navTarget.title, mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource, mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource, thumbnailSource = navTarget.thumbnailSource,
mimeType = navTarget.mimeType,
) )
createNode<MediaViewerNode>(buildContext, listOf(inputs)) createNode<MediaViewerNode>(buildContext, listOf(inputs))
} }
@ -118,30 +117,39 @@ class MessagesFlowNode @AssistedInject constructor(
when (event.content) { when (event.content) {
is TimelineItemImageContent -> { is TimelineItemImageContent -> {
val navTarget = NavTarget.MediaViewer( 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, mediaSource = event.content.mediaSource,
thumbnailSource = event.content.mediaSource, thumbnailSource = event.content.mediaSource,
mimeType = event.content.mimeType
) )
backstack.push(navTarget) backstack.push(navTarget)
} }
is TimelineItemVideoContent -> { is TimelineItemVideoContent -> {
val mediaSource = event.content.videoSource val mediaSource = event.content.videoSource
val navTarget = NavTarget.MediaViewer( val navTarget = NavTarget.MediaViewer(
title = event.content.body, mediaInfo = MediaInfo(
name = event.content.body,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize
),
mediaSource = mediaSource, mediaSource = mediaSource,
thumbnailSource = event.content.thumbnailSource, thumbnailSource = event.content.thumbnailSource,
mimeType = event.content.mimeType,
) )
backstack.push(navTarget) backstack.push(navTarget)
} }
is TimelineItemFileContent -> { is TimelineItemFileContent -> {
val mediaSource = event.content.fileSource val mediaSource = event.content.fileSource
val navTarget = NavTarget.MediaViewer( val navTarget = NavTarget.MediaViewer(
title = event.content.body, mediaInfo = MediaInfo(
name = event.content.body,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize
),
mediaSource = mediaSource, mediaSource = mediaSource,
thumbnailSource = event.content.thumbnailSource, thumbnailSource = event.content.thumbnailSource,
mimeType = event.content.mimeType,
) )
backstack.push(navTarget) backstack.push(navTarget)
} }

View file

@ -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.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
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.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.architecture.Presenter
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.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.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage 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.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@ -55,6 +65,7 @@ class MessagesPresenter @Inject constructor(
private val actionListPresenter: ActionListPresenter, private val actionListPresenter: ActionListPresenter,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
private val messageSummaryFormatter: MessageSummaryFormatter,
) : Presenter<MessagesState> { ) : Presenter<MessagesState> {
@Composable @Composable
@ -145,7 +156,38 @@ class MessagesPresenter @Inject constructor(
private fun handleActionReply(targetEvent: TimelineItem.Event, composerState: MessageComposerState) { private fun handleActionReply(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {
if (targetEvent.eventId == null) return 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( composerState.eventSink(
MessageComposerEvents.SetMode(composerMode) MessageComposerEvents.SetMode(composerMode)
) )

View file

@ -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.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -94,23 +95,11 @@ fun MessagesView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LogCompositions(tag = "MessagesScreen", msg = "Root") LogCompositions(tag = "MessagesScreen", msg = "Root")
val coroutineScope = rememberCoroutineScope()
var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) } var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments) AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
val snackbarMessageText = state.snackbarMessage?.let { stringResource(it.messageResId) }
if (snackbarMessageText != null) {
SideEffect {
coroutineScope.launch {
snackbarHostState.showSnackbar(
message = snackbarMessageText,
duration = state.snackbarMessage.duration
)
}
}
}
// This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose // This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
val localView = LocalView.current val localView = LocalView.current

View file

@ -16,7 +16,6 @@
package io.element.android.features.messages.impl.actionlist package io.element.android.features.messages.impl.actionlist
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -36,19 +35,16 @@ import androidx.compose.material.ListItem
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddReaction 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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview 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.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.components.blurhash.BlurHashAsyncImage
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.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.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent 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.ElementTextStyles
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.Divider
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -189,70 +186,56 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
Text(body, style = contentStyle, maxLines = 1, overflow = TextOverflow.Ellipsis) 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) { when (event.content) {
is TimelineItemTextBasedContent -> content = { ContentForBody(event.content.body) } is TimelineItemTextBasedContent,
is TimelineItemStateContent -> content = { ContentForBody(event.content.body) } is TimelineItemStateContent,
is TimelineItemProfileChangeContent -> content = { ContentForBody(event.content.body) } is TimelineItemProfileChangeContent,
is TimelineItemEncryptedContent -> content = { ContentForBody(stringResource(StringR.string.common_unable_to_decrypt)) } is TimelineItemEncryptedContent,
is TimelineItemRedactedContent -> content = { ContentForBody(stringResource(StringR.string.common_message_removed)) } is TimelineItemRedactedContent,
is TimelineItemUnknownContent -> content = { ContentForBody(stringResource(StringR.string.common_unsupported_event)) } is TimelineItemUnknownContent -> content = { ContentForBody(textContent) }
is TimelineItemImageContent -> { is TimelineItemImageContent -> {
icon = { icon = {
val mediaRequestData = MediaRequestData( AttachmentThumbnail(
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,
modifier = imageModifier, modifier = imageModifier,
info = AttachmentThumbnailInfo(
mediaSource = event.content.mediaSource,
textContent = textContent,
type = AttachmentThumbnailType.File,
blurHash = event.content.blurhash,
)
) )
} }
content = { ContentForBody(event.content.body) } content = { ContentForBody(event.content.body) }
} }
is TimelineItemVideoContent -> { is TimelineItemVideoContent -> {
icon = { icon = {
val thumbnailSource = event.content.thumbnailSource AttachmentThumbnail(
if (thumbnailSource != null) { modifier = imageModifier,
val mediaRequestData = MediaRequestData( info = AttachmentThumbnailInfo(
source = event.content.thumbnailSource, mediaSource = event.content.thumbnailSource,
kind = MediaRequestData.Kind.Thumbnail(32), textContent = textContent,
) type = AttachmentThumbnailType.Video,
BlurHashAsyncImage(
model = mediaRequestData,
blurHash = event.content.blurHash, 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) } content = { ContentForBody(event.content.body) }
} }
is TimelineItemFileContent -> { is TimelineItemFileContent -> {
icon = { icon = {
Box( AttachmentThumbnail(
modifier = imageModifier.background(MaterialTheme.colorScheme.surface), modifier = imageModifier,
contentAlignment = Alignment.Center info = AttachmentThumbnailInfo(
) { mediaSource = null,
Icon( textContent = textContent,
imageVector = Icons.Outlined.Attachment, type = AttachmentThumbnailType.File,
contentDescription = stringResource(StringR.string.common_file), blurHash = null
modifier = Modifier.rotate(-45f)
) )
} )
} }
content = { ContentForBody(event.content.body) } content = { ContentForBody(event.content.body) }
} }

View file

@ -84,7 +84,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
sendActionState: MutableState<Async<Unit>>, sendActionState: MutableState<Async<Unit>>,
) { ) {
suspend { suspend {
mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.mimeType, mediaAttachment.compressIfPossible) mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.info.mimeType, mediaAttachment.compressIfPossible)
}.executeResult(sendActionState) }.executeResult(sendActionState)
} }
} }

View file

@ -20,21 +20,27 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import io.element.android.features.messages.impl.attachments.Attachment 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.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.architecture.Async
import io.element.android.libraries.core.mimetype.MimeTypes
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> { open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
override val values: Sequence<AttachmentsPreviewState> override val values: Sequence<AttachmentsPreviewState>
get() = sequenceOf( get() = sequenceOf(
anAttachmentsPreviewState(), anAttachmentsPreviewState(),
anAttachmentsPreviewState(mediaInfo = aFileInfo()),
anAttachmentsPreviewState(sendActionState = Async.Loading()), anAttachmentsPreviewState(sendActionState = Async.Loading()),
anAttachmentsPreviewState(sendActionState = Async.Failure(RuntimeException())), 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( attachment = Attachment.Media(
localMedia = LocalMedia("path".toUri(), MimeTypes.Jpeg, "an image", 1000L), localMedia = LocalMedia("file://path".toUri(), mediaInfo),
compressIfPossible = true compressIfPossible = true
), ),
sendActionState = sendActionState, sendActionState = sendActionState,

View file

@ -121,7 +121,8 @@ private fun AttachmentPreviewContent(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f) .weight(1f),
contentAlignment = Alignment.Center,
) { ) {
when (attachment) { when (attachment) {
is Attachment.Media -> LocalMediaView( is Attachment.Media -> LocalMediaView(

View file

@ -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)")
}
}
}

View file

@ -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()
}
}

View file

@ -20,33 +20,50 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding 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.getFileName
import io.element.android.libraries.androidutils.file.getFileSize 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.core.mimetype.MimeTypes
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.media.toFile
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class AndroidLocalMediaFactory @Inject constructor( class AndroidLocalMediaFactory @Inject constructor(
@ApplicationContext private val context: Context @ApplicationContext private val context: Context,
private val fileSizeFormatter: FileSizeFormatter,
) : LocalMediaFactory { ) : LocalMediaFactory {
override fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia { override fun createFromMediaFile(mediaFile: MediaFile, mediaInfo: MediaInfo): LocalMedia {
val uri = mediaFile.path().toUri() val uri = mediaFile.toFile().toUri()
return createFromUri(uri, mimeType) return createFromUri(
uri = uri,
mimeType = mediaInfo.mimeType,
name = mediaInfo.name,
formattedFileSize = mediaInfo.formattedFileSize
)
} }
override fun createFromUri(uri: Uri, mimeType: String?): LocalMedia { override fun createFromUri(
val resolvedMimeType = mimeType ?: context.contentResolver.getType(uri) ?: MimeTypes.OctetStream uri: Uri,
val fileName = context.getFileName(uri) mimeType: String?,
val fileSize = context.getFileSize(uri) 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( return LocalMedia(
uri = uri, uri = uri,
mimeType = resolvedMimeType, info = MediaInfo(
name = fileName, mimeType = resolvedMimeType,
size = fileSize name = fileName,
formattedFileSize = fileSize
)
) )
} }
} }

View file

@ -19,22 +19,11 @@ package io.element.android.features.messages.impl.media.local
import android.net.Uri import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@Immutable @Immutable
data class LocalMedia( data class LocalMedia(
val uri: Uri, val uri: Uri,
val mimeType: String, val info: MediaInfo,
val name: String?, ) : Parcelable
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
}
}

View file

@ -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>
}

View file

@ -22,13 +22,21 @@ import io.element.android.libraries.matrix.api.media.MediaFile
interface LocalMediaFactory { 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] * This method will create a [LocalMedia] with the given mimeType, name and formattedFileSize
* If the [mimeType] is null, it'll try to read it from the content. * 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
} }

View file

@ -17,18 +17,37 @@
package io.element.android.features.messages.impl.media.local package io.element.android.features.messages.impl.media.local
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.net.Uri
import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.compose.foundation.Image 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.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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource 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.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
@ -36,6 +55,7 @@ import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView 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.exoplayer.ExoPlayerWrapper
import io.element.android.features.messages.impl.media.local.pdf.PdfViewer import io.element.android.features.messages.impl.media.local.pdf.PdfViewer
import io.element.android.features.messages.impl.media.local.pdf.rememberPdfViewerState 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.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.R 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 io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.ZoomableState import me.saket.telephoto.zoomable.ZoomableState
@ -55,39 +78,45 @@ import me.saket.telephoto.zoomable.rememberZoomableState
fun LocalMediaView( fun LocalMediaView(
localMedia: LocalMedia?, localMedia: LocalMedia?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
mimeType: String? = localMedia?.mimeType, localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
onReady: () -> Unit = {}, mediaInfo: MediaInfo? = localMedia?.info,
) { ) {
val zoomableState = rememberZoomableState( val zoomableState = rememberZoomableState(
zoomSpec = ZoomSpec(maxZoomFactor = 5f) zoomSpec = ZoomSpec(maxZoomFactor = 5f)
) )
val mimeType = mediaInfo?.mimeType
when { when {
mimeType.isMimeTypeImage() -> MediaImageView( mimeType.isMimeTypeImage() -> MediaImageView(
localMediaViewState = localMediaViewState,
localMedia = localMedia, localMedia = localMedia,
zoomableState = zoomableState, zoomableState = zoomableState,
onReady = onReady,
modifier = modifier modifier = modifier
) )
mimeType.isMimeTypeVideo() -> MediaVideoView( mimeType.isMimeTypeVideo() -> MediaVideoView(
localMediaViewState = localMediaViewState,
localMedia = localMedia, localMedia = localMedia,
onReady = onReady,
modifier = modifier modifier = modifier
) )
mimeType == MimeTypes.Pdf -> MediaPDFView( mimeType == MimeTypes.Pdf -> MediaPDFView(
localMediaViewState = localMediaViewState,
localMedia = localMedia, localMedia = localMedia,
zoomableState = zoomableState, zoomableState = zoomableState,
onReady = onReady,
modifier = modifier modifier = modifier
) )
else -> Unit else -> MediaFileView(
localMediaViewState = localMediaViewState,
uri = localMedia?.uri,
info = mediaInfo,
modifier = modifier
)
} }
} }
@Composable @Composable
private fun MediaImageView( private fun MediaImageView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?, localMedia: LocalMedia?,
zoomableState: ZoomableState, zoomableState: ZoomableState,
onReady: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (LocalInspectionMode.current) { if (LocalInspectionMode.current) {
@ -98,15 +127,11 @@ private fun MediaImageView(
) )
} else { } else {
val zoomableImageState = rememberZoomableImageState(zoomableState) val zoomableImageState = rememberZoomableImageState(zoomableState)
LaunchedEffect(zoomableImageState.isImageDisplayed) { localMediaViewState.isReady = zoomableImageState.isImageDisplayed
if (zoomableImageState.isImageDisplayed) {
onReady()
}
}
ZoomableAsyncImage( ZoomableAsyncImage(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
state = zoomableImageState, state = zoomableImageState,
model = localMedia?.model, model = localMedia?.uri,
contentDescription = "Image", contentDescription = "Image",
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
) )
@ -116,14 +141,14 @@ private fun MediaImageView(
@UnstableApi @UnstableApi
@Composable @Composable
fun MediaVideoView( fun MediaVideoView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?, localMedia: LocalMedia?,
onReady: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val playerListener = object : Player.Listener { val playerListener = object : Player.Listener {
override fun onRenderedFirstFrame() { override fun onRenderedFirstFrame() {
onReady() localMediaViewState.isReady = true
} }
} }
val exoPlayer = remember { val exoPlayer = remember {
@ -170,19 +195,64 @@ fun MediaVideoView(
@Composable @Composable
fun MediaPDFView( fun MediaPDFView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?, localMedia: LocalMedia?,
zoomableState: ZoomableState, zoomableState: ZoomableState,
onReady: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val pdfViewerState = rememberPdfViewerState( val pdfViewerState = rememberPdfViewerState(
model = localMedia?.model, model = localMedia?.uri,
zoomableState = zoomableState zoomableState = zoomableState
) )
LaunchedEffect(pdfViewerState.isLoaded) { localMediaViewState.isReady = pdfViewerState.isLoaded
if (pdfViewerState.isLoaded) {
onReady()
}
}
PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier) 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
)
}
}
}
}

View file

@ -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()
}
}

View file

@ -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"
)

View file

@ -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
}
}

View file

@ -43,11 +43,11 @@ class PdfRendererManager(
mutex.withLock { mutex.withLock {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
pdfRenderer = PdfRenderer(parcelFileDescriptor).apply { pdfRenderer = PdfRenderer(parcelFileDescriptor).apply {
(0 until pageCount).map { pageIndex -> // Preload just 3 pages so we can render faster
PdfPage(width, pageIndex, mutex, this, coroutineScope) val firstPages = loadPages(from = 0, to = 3)
}.also { mutablePdfPages.value = firstPages
mutablePdfPages.value = it 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)
}
}
} }

View file

@ -17,6 +17,9 @@
package io.element.android.features.messages.impl.media.viewer package io.element.android.features.messages.impl.media.viewer
sealed interface MediaViewerEvents { sealed interface MediaViewerEvents {
object SaveOnDisk: MediaViewerEvents
object Share: MediaViewerEvents
object OpenWith: MediaViewerEvents
object RetryLoading : MediaViewerEvents object RetryLoading : MediaViewerEvents
object ClearLoadingError : MediaViewerEvents object ClearLoadingError : MediaViewerEvents
} }

View file

@ -24,6 +24,7 @@ import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.theme.ForcedDarkElementTheme import io.element.android.libraries.designsystem.theme.ForcedDarkElementTheme
@ -38,10 +39,9 @@ class MediaViewerNode @AssistedInject constructor(
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
data class Inputs( data class Inputs(
val name: String, val mediaInfo: MediaInfo,
val mediaSource: MediaSource, val mediaSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val mimeType: String?
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
@ -54,7 +54,8 @@ class MediaViewerNode @AssistedInject constructor(
val state = presenter.present() val state = presenter.present()
MediaViewerView( MediaViewerView(
state = state, state = state,
modifier = modifier modifier = modifier,
onBackPressed = this::navigateUp
) )
} }
} }

View file

@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.media.viewer package io.element.android.features.messages.impl.media.viewer
import android.content.ActivityNotFoundException
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
@ -28,18 +29,26 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.media.local.LocalMedia 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.features.messages.impl.media.local.LocalMediaFactory
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter 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.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaFile
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch 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( class MediaViewerPresenter @AssistedInject constructor(
@Assisted private val inputs: MediaViewerNode.Inputs, @Assisted private val inputs: MediaViewerNode.Inputs,
private val localMediaFactory: LocalMediaFactory, private val localMediaFactory: LocalMediaFactory,
private val mediaLoader: MatrixMediaLoader, private val mediaLoader: MatrixMediaLoader,
private val localMediaActions: LocalMediaActions,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<MediaViewerState> { ) : Presenter<MediaViewerState> {
@AssistedFactory @AssistedFactory
@ -57,6 +66,8 @@ class MediaViewerPresenter @AssistedInject constructor(
val localMedia: MutableState<Async<LocalMedia>> = remember { val localMedia: MutableState<Async<LocalMedia>> = remember {
mutableStateOf(Async.Uninitialized) mutableStateOf(Async.Uninitialized)
} }
val snackbarMessage = handleSnackbarMessage(snackbarDispatcher)
localMediaActions.Configure()
DisposableEffect(loadMediaTrigger) { DisposableEffect(loadMediaTrigger) {
coroutineScope.downloadMedia(mediaFile, localMedia) coroutineScope.downloadMedia(mediaFile, localMedia)
onDispose { onDispose {
@ -68,29 +79,84 @@ class MediaViewerPresenter @AssistedInject constructor(
when (mediaViewerEvents) { when (mediaViewerEvents) {
MediaViewerEvents.RetryLoading -> loadMediaTrigger++ MediaViewerEvents.RetryLoading -> loadMediaTrigger++
MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized 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( return MediaViewerState(
name = inputs.name, mediaInfo = inputs.mediaInfo,
mimeType = inputs.mimeType,
thumbnailSource = inputs.thumbnailSource, thumbnailSource = inputs.thumbnailSource,
downloadedMedia = localMedia.value, downloadedMedia = localMedia.value,
snackbarMessage = snackbarMessage,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
private fun CoroutineScope.downloadMedia(mediaFile: MutableState<MediaFile?>, localMedia: MutableState<Async<LocalMedia>>) = launch { private fun CoroutineScope.downloadMedia(mediaFile: MutableState<MediaFile?>, localMedia: MutableState<Async<LocalMedia>>) = launch {
localMedia.value = Async.Loading() localMedia.value = Async.Loading()
mediaLoader.downloadMediaFile(inputs.mediaSource, inputs.mimeType) mediaLoader.downloadMediaFile(
source = inputs.mediaSource,
mimeType = inputs.mediaInfo.mimeType,
body = inputs.mediaInfo.name
)
.onSuccess { .onSuccess {
mediaFile.value = it mediaFile.value = it
}.mapCatching { }.mapCatching { mediaFile ->
localMediaFactory.createFromMediaFile(it, inputs.mimeType) localMediaFactory.createFromMediaFile(
mediaFile = mediaFile,
mediaInfo = inputs.mediaInfo
)
}.onSuccess { }.onSuccess {
localMedia.value = Async.Success(it) localMedia.value = Async.Success(it)
}.onFailure { }.onFailure {
localMedia.value = Async.Failure(it) 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
}
}
} }

View file

@ -17,13 +17,15 @@
package io.element.android.features.messages.impl.media.viewer package io.element.android.features.messages.impl.media.viewer
import io.element.android.features.messages.impl.media.local.LocalMedia 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.architecture.Async
import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
data class MediaViewerState( data class MediaViewerState(
val name: String, val mediaInfo: MediaInfo,
val mimeType: String?,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val downloadedMedia: Async<LocalMedia>, val downloadedMedia: Async<LocalMedia>,
val snackbarMessage: SnackbarMessage?,
val eventSink: (MediaViewerEvents) -> Unit, val eventSink: (MediaViewerEvents) -> Unit,
) )

View file

@ -18,8 +18,12 @@ package io.element.android.features.messages.impl.media.viewer
import android.net.Uri import android.net.Uri
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.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 import io.element.android.libraries.architecture.Async
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> { open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
@ -30,24 +34,41 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
aMediaViewerState(Async.Failure(IllegalStateException())), aMediaViewerState(Async.Failure(IllegalStateException())),
aMediaViewerState( aMediaViewerState(
Async.Success( Async.Success(
LocalMedia( LocalMedia(Uri.EMPTY, anImageInfo())
Uri.EMPTY, MimeTypes.IMAGE_JPEG, "an image file", 100L
)
), ),
anImageInfo(),
), ),
aMediaViewerState( aMediaViewerState(
Async.Success( Async.Success(
LocalMedia( LocalMedia(Uri.EMPTY, aVideoInfo())
Uri.EMPTY, MimeTypes.VIDEO_MP4, "a video file", 100L
)
), ),
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( fun aMediaViewerState(
name = "A media", downloadedMedia: Async<LocalMedia> = Async.Uninitialized,
mimeType = MimeTypes.IMAGE_JPEG, mediaInfo: MediaInfo = anImageInfo(),
) = MediaViewerState(
mediaInfo = mediaInfo,
thumbnailSource = null, thumbnailSource = null,
downloadedMedia = downloadedMedia, downloadedMedia = downloadedMedia,
snackbarMessage = null
) {} ) {}

View file

@ -14,6 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.messages.impl.media.viewer package io.element.android.features.messages.impl.media.viewer
@ -21,8 +22,21 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -32,18 +46,25 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage 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.LocalMediaView
import io.element.android.features.messages.impl.media.local.rememberLocalMediaViewState
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.isLoading 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.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.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.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.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.MediaRequestData
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -52,6 +73,7 @@ import io.element.android.libraries.ui.strings.R as StringR
@Composable @Composable
fun MediaViewerView( fun MediaViewerView(
state: MediaViewerState, state: MediaViewerState,
onBackPressed: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -63,61 +85,132 @@ fun MediaViewerView(
state.eventSink(MediaViewerEvents.ClearLoadingError) state.eventSink(MediaViewerEvents.ClearLoadingError)
} }
var showProgress by remember { val localMediaViewState = rememberLocalMediaViewState()
mutableStateOf(false) 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. Scaffold(
// When sdk will expose download progress we'll be able to remove this. modifier,
LaunchedEffect(state.downloadedMedia) { topBar = {
showProgress = false MediaViewerTopBar(
delay(100) actionsEnabled = state.downloadedMedia is Async.Success,
if (state.downloadedMedia.isLoading()) { onBackPressed = onBackPressed,
showProgress = true eventSink = state.eventSink
} )
} },
snackbarHost = {
var showThumbnail by remember { SnackbarHost(snackbarHostState) { data ->
mutableStateOf(true) Snackbar(
} snackbarData = data,
containerColor = MaterialTheme.colorScheme.surfaceVariant,
fun onMediaReady() { contentColor = MaterialTheme.colorScheme.primary
showThumbnail = false )
} }
},
Scaffold(modifier) { ) {
Box( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(it), .padding(it),
contentAlignment = Alignment.Center
) { ) {
if (state.downloadedMedia is Async.Failure) { if (showProgress) {
ErrorView( LinearProgressIndicator(
errorMessage = stringResource(id = StringR.string.error_unknown), Modifier
onRetry = ::onRetry, .fillMaxWidth()
onDismiss = ::onDismissError .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 @Composable
private fun ThumbnailView( private fun ThumbnailView(
thumbnailSource: MediaSource?, thumbnailSource: MediaSource?,
showThumbnail: Boolean, showThumbnail: Boolean,
showProgress: Boolean,
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = showThumbnail, visible = showThumbnail,
@ -139,14 +232,6 @@ private fun ThumbnailView(
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
contentDescription = null, 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) { private fun ContentToPreview(state: MediaViewerState) {
MediaViewerView( MediaViewerView(
state = state, state = state,
onBackPressed = {}
) )
} }

View file

@ -192,7 +192,7 @@ class MessageComposerPresenter @Inject constructor(
is Attachment.Media -> { is Attachment.Media -> {
sendMedia( sendMedia(
uri = attachment.localMedia.uri, uri = attachment.localMedia.uri,
mimeType = attachment.localMedia.mimeType, mimeType = attachment.localMedia.info.mimeType,
attachmentState = attachmentState attachmentState = attachmentState
) )
} }
@ -210,12 +210,17 @@ class MessageComposerPresenter @Inject constructor(
attachmentsState.value = AttachmentsState.None attachmentsState.value = AttachmentsState.None
return 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 mediaAttachment = Attachment.Media(localMedia, compressIfPossible)
val isPreviewable = when { val isPreviewable = when {
MimeTypes.isImage(localMedia.mimeType) -> true MimeTypes.isImage(localMedia.info.mimeType) -> true
MimeTypes.isVideo(localMedia.mimeType) -> true MimeTypes.isVideo(localMedia.info.mimeType) -> true
MimeTypes.isAudio(localMedia.mimeType) -> true MimeTypes.isAudio(localMedia.info.mimeType) -> true
else -> false else -> false
} }
attachmentsState.value = if (isPreviewable) { attachmentsState.value = if (isPreviewable) {

View file

@ -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.core.UserId
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline 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.EventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlin.random.Random import kotlin.random.Random
@ -96,6 +97,7 @@ internal fun aTimelineItemEvent(
content: TimelineItemEventContent = aTimelineItemTextContent(), content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: EventSendState = EventSendState.Sent(eventId), sendState: EventSendState = EventSendState.Sent(eventId),
inReplyTo: InReplyTo? = null,
): TimelineItem.Event { ): TimelineItem.Event {
return TimelineItem.Event( return TimelineItem.Event(
id = eventId.value, id = eventId.value,
@ -113,5 +115,6 @@ internal fun aTimelineItemEvent(
senderDisplayName = "sender", senderDisplayName = "sender",
groupPosition = groupPosition, groupPosition = groupPosition,
sendState = sendState, sendState = sendState,
inReplyTo = inReplyTo,
) )
} }

View file

@ -17,30 +17,16 @@
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column 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.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.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.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -55,39 +41,24 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.LastBaseline
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.components.MessageEventBubble import io.element.android.features.messages.impl.timeline.components.TimelineItemEventRow
import io.element.android.features.messages.impl.timeline.components.MessageStateEventContainer import io.element.android.features.messages.impl.timeline.components.TimelineItemStateEventRow
import io.element.android.features.messages.impl.timeline.components.TimelineEventTimestampView import io.element.android.features.messages.impl.timeline.components.TimelineItemVirtualRow
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.group.GroupHeaderView 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.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.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.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.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight 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.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@ -101,12 +72,16 @@ fun TimelineView(
onMessageClicked: (TimelineItem.Event) -> Unit = {}, onMessageClicked: (TimelineItem.Event) -> Unit = {},
onMessageLongClicked: (TimelineItem.Event) -> Unit = {}, onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
) { ) {
fun onReachedLoadMore() { fun onReachedLoadMore() {
state.eventSink(TimelineEvents.LoadMore) state.eventSink(TimelineEvents.LoadMore)
} }
val lazyListState = rememberLazyListState() 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) { Box(modifier = modifier) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -124,6 +99,7 @@ fun TimelineView(
onClick = onMessageClicked, onClick = onMessageClicked,
onLongClick = onMessageLongClicked, onLongClick = onMessageLongClicked,
onUserDataClick = onUserDataClicked, onUserDataClick = onUserDataClicked,
inReplyToClick = ::inReplyToClicked,
) )
if (index == state.timelineItems.lastIndex) { if (index == state.timelineItems.lastIndex) {
onReachedLoadMore() onReachedLoadMore()
@ -146,6 +122,7 @@ fun TimelineItemRow(
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
when (timelineItem) { when (timelineItem) {
@ -179,6 +156,7 @@ fun TimelineItemRow(
onClick = ::onClick, onClick = ::onClick,
onLongClick = ::onLongClick, onLongClick = ::onLongClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
inReplyToClick = inReplyToClick,
modifier = modifier, modifier = modifier,
) )
} }
@ -209,6 +187,7 @@ fun TimelineItemRow(
highlightedItem = highlightedItem, highlightedItem = highlightedItem,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick, 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 @Composable
internal fun BoxScope.TimelineScrollHelper( internal fun BoxScope.TimelineScrollHelper(
lazyListState: LazyListState, lazyListState: LazyListState,

View file

@ -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
}

View file

@ -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()
)
}
}
}

View file

@ -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
}
}

View file

@ -20,8 +20,9 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Attachment import androidx.compose.material.icons.outlined.Attachment
@ -61,10 +62,13 @@ fun TimelineItemFileView(
Icon( Icon(
imageVector = Icons.Outlined.Attachment, imageVector = Icons.Outlined.Attachment,
contentDescription = "OpenFile", 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(
text = content.body, text = content.body,
maxLines = 2, maxLines = 2,
@ -74,6 +78,9 @@ fun TimelineItemFileView(
Text( Text(
text = content.fileExtensionAndSize, text = content.fileExtensionAndSize,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
} }
} }

View file

@ -22,20 +22,23 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.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.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.MediaRequestData
import kotlin.math.max
@Composable @Composable
fun TimelineItemImageView( fun TimelineItemImageView(
content: TimelineItemImageContent, content: TimelineItemImageContent,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
// TODO place this value somewhere else?
val minHeight = max(100, content.height ?: 0)
TimelineItemAspectRatioBox( TimelineItemAspectRatioBox(
height = content.height, height = minHeight,
aspectRatio = content.aspectRatio, aspectRatio = content.aspectRatio,
modifier = modifier modifier = modifier
) { ) {

View file

@ -29,9 +29,9 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.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.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider 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.modifiers.roundedBackground
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight

View file

@ -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.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter
import io.element.android.features.messages.impl.timeline.util.toHtmlDocument import io.element.android.features.messages.impl.timeline.util.toHtmlDocument
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
@ -51,11 +52,12 @@ class TimelineItemContentMessageFactory @Inject constructor(
TimelineItemImageContent( TimelineItemImageContent(
body = messageType.body, body = messageType.body,
mediaSource = messageType.source, mediaSource = messageType.source,
mimeType = messageType.info?.mimetype, mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
blurhash = messageType.info?.blurhash, blurhash = messageType.info?.blurhash,
width = messageType.info?.width?.toInt(), width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(), height = messageType.info?.height?.toInt(),
aspectRatio = aspectRatio aspectRatio = aspectRatio,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0)
) )
} }
is VideoMessageType -> { is VideoMessageType -> {
@ -64,22 +66,21 @@ class TimelineItemContentMessageFactory @Inject constructor(
body = messageType.body, body = messageType.body,
thumbnailSource = messageType.info?.thumbnailSource, thumbnailSource = messageType.info?.thumbnailSource,
videoSource = messageType.source, videoSource = messageType.source,
mimeType = messageType.info?.mimetype, mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
width = messageType.info?.width?.toInt(), width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(), height = messageType.info?.height?.toInt(),
duration = messageType.info?.duration ?: 0L, duration = messageType.info?.duration ?: 0L,
blurHash = messageType.info?.blurhash, blurHash = messageType.info?.blurhash,
aspectRatio = aspectRatio aspectRatio = aspectRatio,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0)
) )
} }
is FileMessageType -> TimelineItemFileContent( is FileMessageType -> TimelineItemFileContent(
body = messageType.body, body = messageType.body,
thumbnailSource = messageType.info?.thumbnailSource, thumbnailSource = messageType.info?.thumbnailSource,
fileSource = messageType.source, fileSource = messageType.source,
mimeType = messageType.info?.mimetype, mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
formattedFileSize = messageType.info?.size?.let { formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0)
fileSizeFormatter.format(it)
},
) )
is NoticeMessageType -> TimelineItemNoticeContent( is NoticeMessageType -> TimelineItemNoticeContent(
body = messageType.body, body = messageType.body,

View file

@ -81,6 +81,7 @@ class TimelineItemEventFactory @Inject constructor(
groupPosition = groupPosition, groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState(), reactionsState = currentTimelineItem.computeReactionsState(),
sendState = currentTimelineItem.event.localSendState ?: EventSendState.NotSentYet, sendState = currentTimelineItem.event.localSendState ?: EventSendState.NotSentYet,
inReplyTo = currentTimelineItem.event.inReplyTo(),
) )
} }

View file

@ -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.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState 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.ImmutableList
@Immutable @Immutable
@ -59,6 +60,7 @@ sealed interface TimelineItem {
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
val reactionsState: TimelineItemReactions, val reactionsState: TimelineItemReactions,
val sendState: EventSendState, val sendState: EventSendState,
val inReplyTo: InReplyTo?,
) : TimelineItem { ) : TimelineItem {
val showSenderInformation = groupPosition.isNew() && !isMine val showSenderInformation = groupPosition.isNew() && !isMine

View file

@ -16,23 +16,17 @@
package io.element.android.features.messages.impl.timeline.model.event 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 import io.element.android.libraries.matrix.api.media.MediaSource
data class TimelineItemFileContent( data class TimelineItemFileContent(
val body: String, val body: String,
val fileSource: MediaSource, val fileSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val formattedFileSize: String?, val formattedFileSize: String,
val mimeType: String?, val mimeType: String,
) : TimelineItemEventContent { ) : TimelineItemEventContent {
override val type: String = "TimelineItemFileContent" override val type: String = "TimelineItemFileContent"
private val fileExtension = body.substringAfterLast('.', "").uppercase() val fileExtensionAndSize = formatFileExtensionAndSize(body, formattedFileSize)
val fileExtensionAndSize = buildString {
append(fileExtension)
if (formattedFileSize != null) {
append(' ')
append("($formattedFileSize)")
}
}
} }

View file

@ -21,7 +21,8 @@ import io.element.android.libraries.matrix.api.media.MediaSource
data class TimelineItemImageContent( data class TimelineItemImageContent(
val body: String, val body: String,
val mediaSource: MediaSource, val mediaSource: MediaSource,
val mimeType: String?, val formattedFileSize: String,
val mimeType: String,
val blurhash: String?, val blurhash: String?,
val width: Int?, val width: Int?,
val height: Int?, val height: Int?,

View file

@ -36,5 +36,6 @@ fun aTimelineItemImageContent() = TimelineItemImageContent(
blurhash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr", blurhash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
width = null, width = null,
height = 300, height = 300,
aspectRatio = 0.5f aspectRatio = 0.5f,
formattedFileSize = "4MB"
) )

View file

@ -27,7 +27,8 @@ data class TimelineItemVideoContent(
val blurHash: String?, val blurHash: String?,
val height: Int?, val height: Int?,
val width: Int?, val width: Int?,
val mimeType: String?, val mimeType: String,
val formattedFileSize: String,
) : TimelineItemEventContent { ) : TimelineItemEventContent {
override val type: String = "TimelineItemImageContent" override val type: String = "TimelineItemImageContent"
} }

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineItemVideoContent> { open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineItemVideoContent> {
@ -37,5 +38,6 @@ fun aTimelineItemVideoContent() = TimelineItemVideoContent(
videoSource = MediaSource(""), videoSource = MediaSource(""),
height = 300, height = 300,
width = 150, width = 150,
mimeType = null mimeType = MimeTypes.Mp4,
formattedFileSize = "14MB"
) )

View file

@ -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
}

View file

@ -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)
}
}
}

View file

@ -9,5 +9,6 @@
<string name="screen_room_attachment_source_camera_video">"Record a video"</string> <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_files">"Attachment"</string>
<string name="screen_room_attachment_source_gallery">"Photo &amp; Video Library"</string> <string name="screen_room_attachment_source_gallery">"Photo &amp; 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> <string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
</resources> </resources>

View file

@ -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.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.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.media.FakeLocalMediaFactory
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType 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.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService 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.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.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
@ -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 @Test
fun `present - handle action edit`() = runTest { fun `present - handle action edit`() = runTest {
val presenter = createMessagePresenter() val presenter = createMessagePresenter()
@ -197,6 +304,7 @@ class MessagesPresenterTest {
actionListPresenter = actionListPresenter, actionListPresenter = actionListPresenter,
networkMonitor = FakeNetworkMonitor(), networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),
messageSummaryFormatter = FakeMessageSummaryFormatter(),
) )
} }
} }

View file

@ -245,5 +245,6 @@ private fun aMessageEvent(
sentTime = "", sentTime = "",
isMine = isMine, isMine = isMine,
reactionsState = TimelineItemReactions(persistentListOf()), reactionsState = TimelineItemReactions(persistentListOf()),
sendState = EventSendState.Sent(AN_EVENT_ID) sendState = EventSendState.Sent(AN_EVENT_ID),
inReplyTo = null,
) )

View file

@ -92,7 +92,6 @@ class AttachmentsPreviewPresenterTest {
private fun anAttachmentsPreviewPresenter( private fun anAttachmentsPreviewPresenter(
localMedia: LocalMedia = aLocalMedia( localMedia: LocalMedia = aLocalMedia(
uri = mockMediaUrl, uri = mockMediaUrl,
mimeType = MimeTypes.IMAGE_JPEG
), ),
room: MatrixRoom = FakeMatrixRoom() room: MatrixRoom = FakeMatrixRoom()
): AttachmentsPreviewPresenter { ): AttachmentsPreviewPresenter {

View file

@ -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.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.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.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_USER_ID 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 import kotlinx.collections.immutable.persistentListOf
internal fun aMessageEvent( internal fun aMessageEvent(
eventId: EventId? = AN_EVENT_ID,
isMine: Boolean = true, isMine: Boolean = true,
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false), content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
inReplyTo: InReplyTo? = null,
) = TimelineItem.Event( ) = TimelineItem.Event(
id = AN_EVENT_ID.value, id = eventId?.value.orEmpty(),
eventId = AN_EVENT_ID, eventId = eventId,
senderId = A_USER_ID, senderId = A_USER_ID,
senderDisplayName = A_USER_NAME, senderDisplayName = A_USER_NAME,
senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME), senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME),
@ -41,5 +45,6 @@ internal fun aMessageEvent(
sentTime = "", sentTime = "",
isMine = isMine, isMine = isMine,
reactionsState = TimelineItemReactions(persistentListOf()), reactionsState = TimelineItemReactions(persistentListOf()),
sendState = EventSendState.Sent(AN_EVENT_ID) sendState = EventSendState.Sent(AN_EVENT_ID),
inReplyTo = inReplyTo,
) )

View file

@ -17,21 +17,18 @@
package io.element.android.features.messages.fixtures package io.element.android.features.messages.fixtures
import android.net.Uri 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.attachments.Attachment
import io.element.android.features.messages.impl.media.local.LocalMedia 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( fun aLocalMedia(
uri: Uri, uri: Uri,
mimeType: String = MimeTypes.IMAGE_JPEG, mediaInfo: MediaInfo = anImageInfo(),
name: String = "a media",
size: Long = 1000,
) = LocalMedia( ) = LocalMedia(
uri = uri, uri = uri,
mimeType = mimeType, info = mediaInfo
name = name,
size = size,
) )
fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media( fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media(

View file

@ -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)
}
}
}

View file

@ -20,18 +20,26 @@ import android.net.Uri
import io.element.android.features.messages.fixtures.aLocalMedia 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.LocalMedia
import io.element.android.features.messages.impl.media.local.LocalMediaFactory 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.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaFile
class FakeLocalMediaFactory(private val localMediaUri: Uri) : LocalMediaFactory { class FakeLocalMediaFactory(private val localMediaUri: Uri) : LocalMediaFactory {
var fallbackMimeType: String = MimeTypes.OctetStream var fallbackMimeType: String = MimeTypes.OctetStream
var fallbackName: String = "File name"
var fallbackFileSize = "0B"
override fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia { override fun createFromMediaFile(mediaFile: MediaFile, mediaInfo: MediaInfo): LocalMedia {
return aLocalMedia(uri = localMediaUri, mimeType = mimeType ?: fallbackMimeType) return aLocalMedia(uri = localMediaUri, mediaInfo = mediaInfo)
} }
override fun createFromUri(uri: Uri, mimeType: String?): LocalMedia { override fun createFromUri(uri: Uri, mimeType: String?, name: String?, formattedFileSize: String?): LocalMedia {
return aLocalMedia(uri, mimeType ?: fallbackMimeType) val mediaInfo = MediaInfo(
name = name ?: fallbackName,
mimeType = mimeType ?: fallbackMimeType,
formattedFileSize = formattedFileSize ?: fallbackFileSize
)
return aLocalMedia(uri, mediaInfo)
} }
} }

View file

@ -19,65 +19,122 @@
package io.element.android.features.messages.media.viewer package io.element.android.features.messages.media.viewer
import android.net.Uri import android.net.Uri
import androidx.media3.common.MimeTypes
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.features.messages.impl.media.viewer.MediaViewerEvents 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.MediaViewerNode
import io.element.android.features.messages.impl.media.viewer.MediaViewerPresenter 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.features.messages.media.FakeLocalMediaFactory
import io.element.android.libraries.architecture.Async 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.FakeMediaLoader
import io.element.android.libraries.matrix.test.media.aMediaSource import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
private const val TESTED_MIME_TYPE = MimeTypes.IMAGE_JPEG private val TESTED_MEDIA_INFO = MediaInfo(
private const val TESTED_MEDIA_NAME = "MediaName" name = "",
mimeType = "",
formattedFileSize = ""
)
class MediaViewerPresenterTest { class MediaViewerPresenterTest {
private val mockMediaUrl: Uri = mockk("localMediaUri") private val mockMediaUri: Uri = mockk("localMediaUri")
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl) private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri)
private val mediaLoader = FakeMediaLoader()
@Test @Test
fun `present - download media success scenario`() = runTest { 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) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() var state = awaitItem()
assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized) assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized)
assertThat(initialState.name).isEqualTo(TESTED_MEDIA_NAME) assertThat(state.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
val loadingState = awaitItem() state = awaitItem()
assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java) assertThat(state.downloadedMedia).isInstanceOf(Async.Loading::class.java)
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS + 1) state = awaitItem()
val successState = awaitItem() val successData = state.downloadedMedia.dataOrNull()
val successData = successState.downloadedMedia.dataOrNull() assertThat(state.downloadedMedia).isInstanceOf(Async.Success::class.java)
assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java)
assertThat(successData).isNotNull() 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 @Test
fun `present - download media failure then retry with success scenario`() = runTest { 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) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
mediaLoader.shouldFail = true mediaLoader.shouldFail = true
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized) assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized)
assertThat(initialState.name).isEqualTo(TESTED_MEDIA_NAME) assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java) assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS)
val failureState = awaitItem() val failureState = awaitItem()
assertThat(failureState.downloadedMedia).isInstanceOf(Async.Failure::class.java) assertThat(failureState.downloadedMedia).isInstanceOf(Async.Failure::class.java)
mediaLoader.shouldFail = false mediaLoader.shouldFail = false
@ -86,7 +143,6 @@ class MediaViewerPresenterTest {
skipItems(1) skipItems(1)
val retryLoadingState = awaitItem() val retryLoadingState = awaitItem()
assertThat(retryLoadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java) assertThat(retryLoadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS)
val successState = awaitItem() val successState = awaitItem()
val successData = successState.downloadedMedia.dataOrNull() val successData = successState.downloadedMedia.dataOrNull()
assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java) 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( return MediaViewerPresenter(
inputs = MediaViewerNode.Inputs( inputs = MediaViewerNode.Inputs(
name = TESTED_MEDIA_NAME, mediaInfo = TESTED_MEDIA_INFO,
mediaSource = aMediaSource(), mediaSource = aMediaSource(),
mimeType = mimeType,
thumbnailSource = null thumbnailSource = null
), ),
localMediaFactory = localMediaFactory, localMediaFactory = localMediaFactory,
mediaLoader = mediaLoader mediaLoader = mediaLoader,
localMediaActions = localMediaActions,
snackbarDispatcher = SnackbarDispatcher()
) )
} }
} }

View file

@ -483,5 +483,5 @@ class MessageComposerPresenterTest {
} }
fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE) 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) fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)

View file

@ -42,7 +42,8 @@ class TimelineItemGrouperTest {
senderDisplayName = "", senderDisplayName = "",
content = TimelineItemStateEventContent(body = "a state event"), content = TimelineItemStateEventContent(body = "a state event"),
reactionsState = TimelineItemReactions(emptyList<AggregatedReaction>().toImmutableList()), 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 aNonGroupableItem = aMessageEvent()
private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today")) private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today"))

View file

@ -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
}
}

View file

@ -29,19 +29,19 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.net.toUri 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.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.execute
import io.element.android.libraries.core.mimetype.MimeTypes 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.StateEventType 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.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
class RoomDetailsEditPresenter @Inject constructor( class RoomDetailsEditPresenter @Inject constructor(
@ -139,13 +139,19 @@ class RoomDetailsEditPresenter @Inject constructor(
val results = mutableListOf<Result<Unit>>() val results = mutableListOf<Result<Unit>>()
suspend { suspend {
if (topic.orEmpty().trim() != room.topic.orEmpty().trim()) { 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()) { 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()) { 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() if (results.all { it.isSuccess }) Unit else results.first { it.isFailure }.getOrThrow()
}.execute(action) }.execute(action)
@ -153,14 +159,12 @@ class RoomDetailsEditPresenter @Inject constructor(
private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> { private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> {
return runCatching { return runCatching {
val result = if (avatarUri != null) { if (avatarUri != null) {
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow() as? MediaUploadInfo.Image val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
val byteArray = preprocessed?.file?.readBytes() room.updateAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
byteArray?.let { room.updateAvatar(MimeTypes.Jpeg, it) } ?: error("Could not process the given uri ($avatarUri)")
} else { } else {
room.removeAvatar() room.removeAvatar().getOrThrow()
} }
result.getOrThrow()
} }
} }
} }

View file

@ -21,7 +21,6 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.features.roomdetails.aMatrixRoom import io.element.android.features.roomdetails.aMatrixRoom
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditEvents import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditEvents
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditPresenter 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.StateEventType 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.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.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@ -600,14 +599,9 @@ class RoomDetailsEditPresenterTest {
} }
fakePickerProvider.givenResult(anotherAvatarUri) fakePickerProvider.givenResult(anotherAvatarUri)
fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image( fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.AnyFile(
file = processedFile, file = processedFile,
info = mockk(), info = mockk(),
thumbnailInfo = ThumbnailProcessingInfo(
file = processedFile,
info = mockk(),
blurhash = "",
)
))) )))
} }

View file

@ -41,17 +41,13 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Snackbar import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.noFontPadding
import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator
import io.element.android.libraries.designsystem.utils.LogCompositions 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 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.designsystem.R as DrawableR
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@ -184,21 +180,7 @@ fun RoomListContent(
} }
} }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
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,
)
}
}
}
Scaffold( Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),

View file

@ -38,6 +38,9 @@ android.nonTransitiveRClass=true
org.gradle.caching=true org.gradle.caching=true
org.gradle.configureondemand=true org.gradle.configureondemand=true
org.gradle.parallel=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 # Dummy values for signing secrets / nightly
signing.element.nightly.storePassword=Secret signing.element.nightly.storePassword=Secret

View file

@ -21,7 +21,7 @@ media3 = "1.0.2"
browser = "1.5.0" browser = "1.5.0"
# Compose # Compose
compose_bom = "2023.05.01" compose_bom = "2023.06.00"
composecompiler = "1.4.7" composecompiler = "1.4.7"
# Coroutines # Coroutines
@ -153,7 +153,7 @@ telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", v
# Analytics # Analytics
posthog = "com.posthog.android:posthog:2.0.3" 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" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:main-SNAPSHOT"
# Di # Di

View file

@ -20,17 +20,24 @@ import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns 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) { fun Context.getFileName(uri: Uri): String? = when (uri.scheme) {
ContentResolver.SCHEME_CONTENT -> getContentFileName(uri) 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 { fun Context.getFileSize(uri: Uri): Long {
return when (uri.scheme) { return when (uri.scheme) {
ContentResolver.SCHEME_CONTENT -> getContentFileSize(uri) ContentResolver.SCHEME_CONTENT -> getContentFileSize(uri)
else -> uri.path?.let(::File)?.length() ContentResolver.SCHEME_FILE -> uri.toFile().length()
else -> 0
} ?: 0 } ?: 0
} }

View file

@ -31,6 +31,7 @@ android {
// Should not be there, but this is a POC // Should not be there, but this is a POC
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.accompanist.systemui) implementation(libs.accompanist.systemui)
implementation(libs.vanniktech.blurhash)
implementation(projects.libraries.elementresources) implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn

View file

@ -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()
}
}
}
}

View file

@ -66,6 +66,7 @@ fun Text(
lineHeight: TextUnit = TextUnit.Unspecified, lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip, overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true, softWrap: Boolean = true,
minLines: Int = 1,
maxLines: Int = Int.MAX_VALUE, maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {}, onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current style: TextStyle = LocalTextStyle.current
@ -84,6 +85,7 @@ fun Text(
lineHeight = lineHeight, lineHeight = lineHeight,
overflow = overflow, overflow = overflow,
softWrap = softWrap, softWrap = softWrap,
minLines = minLines,
maxLines = maxLines, maxLines = maxLines,
onTextLayout = onTextLayout, onTextLayout = onTextLayout,
style = style, style = style,
@ -105,6 +107,7 @@ fun Text(
lineHeight: TextUnit = TextUnit.Unspecified, lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip, overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true, softWrap: Boolean = true,
minLines: Int = 1,
maxLines: Int = Int.MAX_VALUE, maxLines: Int = Int.MAX_VALUE,
inlineContent: ImmutableMap<String, InlineTextContent> = persistentMapOf(), inlineContent: ImmutableMap<String, InlineTextContent> = persistentMapOf(),
onTextLayout: (TextLayoutResult) -> Unit = {}, onTextLayout: (TextLayoutResult) -> Unit = {},
@ -124,6 +127,7 @@ fun Text(
lineHeight = lineHeight, lineHeight = lineHeight,
overflow = overflow, overflow = overflow,
softWrap = softWrap, softWrap = softWrap,
minLines = minLines,
maxLines = maxLines, maxLines = maxLines,
inlineContent = inlineContent, inlineContent = inlineContent,
onTextLayout = onTextLayout, onTextLayout = onTextLayout,

View file

@ -18,11 +18,14 @@ package io.element.android.libraries.designsystem.utils
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -56,7 +59,7 @@ fun handleSnackbarMessage(
val snackbarMessage by snackbarDispatcher.snackbarMessage.collectAsState(initial = null) val snackbarMessage by snackbarDispatcher.snackbarMessage.collectAsState(initial = null)
LaunchedEffect(snackbarMessage) { LaunchedEffect(snackbarMessage) {
if (snackbarMessage != null) { if (snackbarMessage != null) {
launch(Dispatchers.Main) { launch {
snackbarDispatcher.clear() snackbarDispatcher.clear()
} }
} }
@ -64,6 +67,25 @@ fun handleSnackbarMessage(
return snackbarMessage 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( data class SnackbarMessage(
@StringRes val messageResId: Int, @StringRes val messageResId: Int,
val duration: SnackbarDuration = SnackbarDuration.Short, val duration: SnackbarDuration = SnackbarDuration.Short,

View file

@ -33,8 +33,9 @@ interface MatrixMediaLoader {
/** /**
* @param source to fetch the data for. * @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] * @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>
} }

View file

@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.api.media package io.element.android.libraries.matrix.api.media
import java.io.Closeable import java.io.Closeable
import java.io.File
/** /**
* A wrapper around a media file on the disk. * A wrapper around a media file on the disk.
@ -25,3 +26,7 @@ import java.io.Closeable
interface MediaFile : Closeable { interface MediaFile : Closeable {
fun path(): String fun path(): String
} }
fun MediaFile.toFile(): File {
return File(path())
}

View file

@ -28,11 +28,25 @@ sealed interface EventContent
data class MessageContent( data class MessageContent(
val body: String, val body: String,
val inReplyTo: EventId?, val inReplyTo: InReplyTo?,
val isEdited: Boolean, val isEdited: Boolean,
val type: MessageType? val type: MessageType?
) : EventContent ) : 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 object RedactedContent : EventContent
data class StickerContent( data class StickerContent(

View file

@ -32,4 +32,12 @@ data class EventTimelineItem(
val senderProfile: ProfileTimelineDetails, val senderProfile: ProfileTimelineDetails,
val timestamp: Long, val timestamp: Long,
val content: EventContent 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
}
}

View file

@ -79,6 +79,7 @@ class RustMatrixClient constructor(
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val baseDirectory: File, private val baseDirectory: File,
private val baseCacheDirectory: File,
private val clock: SystemClock, private val clock: SystemClock,
) : MatrixClient { ) : MatrixClient {
@ -188,7 +189,7 @@ class RustMatrixClient constructor(
override val invitesDataSource: RoomSummaryDataSource override val invitesDataSource: RoomSummaryDataSource
get() = rustInvitesDataSource get() = rustInvitesDataSource
private val rustMediaLoader = RustMediaLoader(dispatchers, client) private val rustMediaLoader = RustMediaLoader(baseCacheDirectory, dispatchers, client)
override val mediaLoader: MatrixMediaLoader override val mediaLoader: MatrixMediaLoader
get() = rustMediaLoader get() = rustMediaLoader

View file

@ -16,10 +16,12 @@
package io.element.android.libraries.matrix.impl.auth package io.element.android.libraries.matrix.impl.auth
import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.di.AppScope 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.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
@ -48,6 +50,7 @@ import org.matrix.rustcomponents.sdk.AuthenticationService as RustAuthentication
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
class RustMatrixAuthenticationService @Inject constructor( class RustMatrixAuthenticationService @Inject constructor(
@ApplicationContext private val context: Context,
private val baseDirectory: File, private val baseDirectory: File,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
@ -179,6 +182,7 @@ class RustMatrixAuthenticationService @Inject constructor(
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
dispatchers = coroutineDispatchers, dispatchers = coroutineDispatchers,
baseDirectory = baseDirectory, baseDirectory = baseDirectory,
baseCacheDirectory = context.cacheDir,
clock = clock, clock = clock,
) )
} }

View file

@ -24,13 +24,21 @@ import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.mediaSourceFromUrl import org.matrix.rustcomponents.sdk.mediaSourceFromUrl
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import java.io.File
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
class RustMediaLoader( class RustMediaLoader(
baseCacheDirectory: File,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val innerClient: Client private val innerClient: Client,
) : MatrixMediaLoader { ) : MatrixMediaLoader {
private val cacheDirectory = File(baseCacheDirectory, "temp/media").apply {
if (!exists()) {
mkdirs()
}
}
@OptIn(ExperimentalUnsignedTypes::class) @OptIn(ExperimentalUnsignedTypes::class)
override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> = override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> =
withContext(dispatchers.io) { 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) { withContext(dispatchers.io) {
runCatching { runCatching {
source.toRustMediaSource().use { mediaSource -> source.toRustMediaSource().use { mediaSource ->
val mediaFile = innerClient.getMediaFile( val mediaFile = innerClient.getMediaFile(
mediaSource = mediaSource, mediaSource = mediaSource,
body = null, body = body,
mimeType = mimeType ?: "application/octet-stream" mimeType = mimeType ?: "application/octet-stream",
//TODO uncomment when rust api will be merged
//tempDir = cacheDirectory.path,
) )
RustMediaFile(mediaFile) RustMediaFile(mediaFile)
} }

View file

@ -16,12 +16,19 @@
package io.element.android.libraries.matrix.impl.timeline 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.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper 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 org.matrix.rustcomponents.sdk.TimelineItem
import timber.log.Timber
class MatrixTimelineItemMapper( class MatrixTimelineItemMapper(
private val room: Room,
private val coroutineScope: CoroutineScope,
private val virtualTimelineItemMapper: VirtualTimelineItemMapper = VirtualTimelineItemMapper(), private val virtualTimelineItemMapper: VirtualTimelineItemMapper = VirtualTimelineItemMapper(),
private val eventTimelineItemMapper: EventTimelineItemMapper= EventTimelineItemMapper(), private val eventTimelineItemMapper: EventTimelineItemMapper= EventTimelineItemMapper(),
) { ) {
@ -30,6 +37,12 @@ class MatrixTimelineItemMapper(
val asEvent = it.asEvent() val asEvent = it.asEvent()
if (asEvent != null) { if (asEvent != null) {
val eventTimelineItem = eventTimelineItemMapper.map(asEvent) val eventTimelineItem = eventTimelineItemMapper.map(asEvent)
if (eventTimelineItem.hasNotLoadedInReplyTo() && eventTimelineItem.eventId != null) {
fetchDetailsForEvent(eventTimelineItem.eventId!!)
}
return MatrixTimelineItem.Event(eventTimelineItem) return MatrixTimelineItem.Event(eventTimelineItem)
} }
val asVirtual = it.asVirtual() val asVirtual = it.asVirtual()
@ -39,4 +52,13 @@ class MatrixTimelineItemMapper(
} }
return MatrixTimelineItem.Other return MatrixTimelineItem.Other
} }
private fun fetchDetailsForEvent(eventId: EventId) = coroutineScope.launch {
runCatching {
room.fetchDetailsForEvent(eventId.value)
}.onFailure {
Timber.e(it)
}
}
} }

View file

@ -63,6 +63,8 @@ class RustMatrixTimeline(
) )
private val timelineItemFactory = MatrixTimelineItemMapper( private val timelineItemFactory = MatrixTimelineItemMapper(
room = innerRoom,
coroutineScope = coroutineScope,
virtualTimelineItemMapper = VirtualTimelineItemMapper(), virtualTimelineItemMapper = VirtualTimelineItemMapper(),
eventTimelineItemMapper = EventTimelineItemMapper( eventTimelineItemMapper = EventTimelineItemMapper(
contentMapper = TimelineEventContentMapper( contentMapper = TimelineEventContentMapper(

View file

@ -17,11 +17,13 @@
package io.element.android.libraries.matrix.impl.timeline.item.event 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.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.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType 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.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType 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 io.element.android.libraries.matrix.impl.media.map
import org.matrix.rustcomponents.sdk.Message import org.matrix.rustcomponents.sdk.Message
import org.matrix.rustcomponents.sdk.MessageType import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.ProfileDetails
import org.matrix.rustcomponents.sdk.RepliedToEventDetails
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody
import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat 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( MessageContent(
body = it.body(), body = it.body(),
inReplyTo = it.inReplyTo()?.eventId?.let(::EventId), inReplyTo = inReplyToEvent,
isEdited = it.isEdited(), isEdited = it.isEdited(),
type = type type = type
) )

View file

@ -26,4 +26,6 @@ dependencies {
api(projects.libraries.core) api(projects.libraries.core)
api(projects.libraries.matrix.api) api(projects.libraries.matrix.api)
api(libs.coroutines.core) api(libs.coroutines.core)
implementation(libs.coroutines.test)
implementation(projects.tests.testutils)
} }

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.test 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.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
@ -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.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.test.StandardTestDispatcher
class FakeMatrixClient( class FakeMatrixClient(
override val sessionId: SessionId = A_SESSION_ID, override val sessionId: SessionId = A_SESSION_ID,
private val coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
private val userDisplayName: Result<String> = Result.success(A_USER_NAME), private val userDisplayName: Result<String> = Result.success(A_USER_NAME),
private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL), private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL),
override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(), override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
override val invitesDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(), override val invitesDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(), override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(coroutineDispatchers),
private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
private val pushersService: FakePushersService = FakePushersService(), private val pushersService: FakePushersService = FakePushersService(),
private val notificationService: FakeNotificationService = FakeNotificationService(), private val notificationService: FakeNotificationService = FakeNotificationService(),

View file

@ -16,40 +16,40 @@
package io.element.android.libraries.matrix.test.media 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.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
import kotlinx.coroutines.delay 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 var shouldFail = false
override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> { override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> = withContext(coroutineDispatchers.io){
delay(FAKE_DELAY_IN_MS) if (shouldFail) {
return if (shouldFail) {
Result.failure(RuntimeException()) Result.failure(RuntimeException())
} else { } else {
return Result.success(ByteArray(0)) Result.success(ByteArray(0))
} }
} }
override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result<ByteArray> { override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result<ByteArray> = withContext(coroutineDispatchers.io){
delay(FAKE_DELAY_IN_MS) if (shouldFail) {
return if (shouldFail) {
Result.failure(RuntimeException()) Result.failure(RuntimeException())
} else { } else {
return Result.success(ByteArray(0)) Result.success(ByteArray(0))
} }
} }
override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?): Result<MediaFile> { override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result<MediaFile> = withContext(coroutineDispatchers.io){
delay(FAKE_DELAY_IN_MS) if (shouldFail) {
return if (shouldFail) {
Result.failure(RuntimeException()) Result.failure(RuntimeException())
} else { } else {
return Result.success(FakeMediaFile("")) Result.success(FakeMediaFile(""))
} }
} }
} }

View file

@ -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

View file

@ -19,7 +19,7 @@ plugins {
} }
android { android {
namespace = "io.element.android.libraries.matrix.test" namespace = "io.element.android.libraries.mediaupload.test"
} }
dependencies { dependencies {

View file

@ -32,6 +32,6 @@ class RetrofitFactory @Inject constructor(
fun create(baseUrl: String): Retrofit = Retrofit.Builder() fun create(baseUrl: String): Retrofit = Retrofit.Builder()
.baseUrl(baseUrl.ensureTrailingSlash()) .baseUrl(baseUrl.ensureTrailingSlash())
.addConverterFactory(json.get().asConverterFactory("application/json".toMediaType())) .addConverterFactory(json.get().asConverterFactory("application/json".toMediaType()))
.callFactory(okHttpClient.get()) .callFactory { request -> okHttpClient.get().newCall(request) }
.build() .build()
} }

View file

@ -7,6 +7,7 @@
<string name="notification_inline_reply_failed">"** Failed to send - please open room"</string> <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_join">"Join"</string>
<string name="notification_invitation_action_reject">"Reject"</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_new_messages">"New Messages"</string>
<string name="notification_room_action_mark_as_read">"Mark as read"</string> <string name="notification_room_action_mark_as_read">"Mark as read"</string>
<string name="notification_sender_me">"Me"</string> <string name="notification_sender_me">"Me"</string>

View file

@ -22,9 +22,6 @@ plugins {
android { android {
namespace = "io.element.android.libraries.textcomposer" namespace = "io.element.android.libraries.textcomposer"
buildFeatures {
viewBinding = true
}
} }
dependencies { dependencies {
@ -33,9 +30,7 @@ dependencies {
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(libs.wysiwyg)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.material)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
} }

View file

@ -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>

View file

@ -18,6 +18,7 @@ package io.element.android.libraries.textcomposer
import android.os.Parcelable import android.os.Parcelable
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
sealed interface MessageComposerMode : Parcelable { sealed interface MessageComposerMode : Parcelable {
@ -38,6 +39,7 @@ sealed interface MessageComposerMode : Parcelable {
@Parcelize @Parcelize
class Reply( class Reply(
val senderName: String, val senderName: String,
val attachmentThumbnailInfo: AttachmentThumbnailInfo?,
override val eventId: EventId, override val eventId: EventId,
override val defaultContent: CharSequence override val defaultContent: CharSequence
) : Special(eventId, defaultContent) ) : Special(eventId, defaultContent)

View file

@ -59,8 +59,10 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.ElementTextStyles 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.Surface
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId 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 import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -180,21 +186,99 @@ private fun ComposerModeView(
) { ) {
when (composerMode) { when (composerMode) {
is MessageComposerMode.Edit -> { is MessageComposerMode.Edit -> {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), EditingModeView(onResetComposerMode = onResetComposerMode, modifier = modifier)
modifier = modifier }
.fillMaxWidth() is MessageComposerMode.Reply -> {
.padding(horizontal = 12.dp, vertical = 8.dp)) { ReplyToModeView(
Icon( modifier = modifier.padding(8.dp),
resourceId = VectorIcons.Edit, senderName = composerMode.senderName,
contentDescription = stringResource(R.string.editing), text = composerMode.defaultContent.toString(),
tint = MaterialTheme.colorScheme.secondary, attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo,
modifier = Modifier.size(16.dp), 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( Text(
stringResource(R.string.editing), senderName,
style = ElementTextStyles.Regular.caption2, style = ElementTextStyles.Regular.caption2.copy(fontWeight = FontWeight.Medium),
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
Icon( Icon(
@ -209,11 +293,19 @@ private fun ComposerModeView(
interactionSource = MutableInteractionSource(), interactionSource = MutableInteractionSource(),
indication = rememberRipple(bounded = false) 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 @Preview
@Composable @Composable
internal fun TextComposerLightPreview() = ElementPreviewLight { ContentToPreview() } internal fun TextComposerSimpleLightPreview() = ElementPreviewLight { SimpleContentToPreview() }
@Preview @Preview
@Composable @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 @Composable
private fun ContentToPreview() { private fun SimpleContentToPreview() {
Column { Column {
TextComposer( TextComposer(
onSendMessage = {}, onSendMessage = {},
@ -322,10 +430,89 @@ private fun ContentToPreview() {
composerCanSendMessage = true, composerCanSendMessage = true,
composerText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", 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( TextComposer(
onSendMessage = {}, onSendMessage = {},
onComposerTextChange = {}, 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 = {}, onResetComposerMode = {},
composerCanSendMessage = true, composerCanSendMessage = true,
composerText = "A message", composerText = "A message",

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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