diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f5eacd03a4..d2d648f145 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,6 +17,9 @@ + + + { val inputs = MediaViewerNode.Inputs( - name = navTarget.title, + mediaInfo = navTarget.mediaInfo, mediaSource = navTarget.mediaSource, thumbnailSource = navTarget.thumbnailSource, - mimeType = navTarget.mimeType, ) createNode(buildContext, listOf(inputs)) } @@ -118,30 +117,39 @@ class MessagesFlowNode @AssistedInject constructor( when (event.content) { is TimelineItemImageContent -> { val navTarget = NavTarget.MediaViewer( - title = event.content.body, + mediaInfo = MediaInfo( + name = event.content.body, + mimeType = event.content.mimeType, + formattedFileSize = event.content.formattedFileSize + ), mediaSource = event.content.mediaSource, thumbnailSource = event.content.mediaSource, - mimeType = event.content.mimeType ) backstack.push(navTarget) } is TimelineItemVideoContent -> { val mediaSource = event.content.videoSource val navTarget = NavTarget.MediaViewer( - title = event.content.body, + mediaInfo = MediaInfo( + name = event.content.body, + mimeType = event.content.mimeType, + formattedFileSize = event.content.formattedFileSize + ), mediaSource = mediaSource, thumbnailSource = event.content.thumbnailSource, - mimeType = event.content.mimeType, ) backstack.push(navTarget) } is TimelineItemFileContent -> { val mediaSource = event.content.fileSource val navTarget = NavTarget.MediaViewer( - title = event.content.body, + mediaInfo = MediaInfo( + name = event.content.body, + mimeType = event.content.mimeType, + formattedFileSize = event.content.formattedFileSize + ), mediaSource = mediaSource, thumbnailSource = event.content.thumbnailSource, - mimeType = event.content.mimeType, ) backstack.push(navTarget) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index cbaeb26c93..015e76f448 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -33,16 +33,26 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize -import io.element.android.libraries.matrix.api.room.MatrixRoom -import io.element.android.libraries.textcomposer.MessageComposerMode -import io.element.android.features.networkmonitor.api.NetworkMonitor -import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.handleSnackbarMessage +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.libraries.textcomposer.MessageComposerMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import timber.log.Timber @@ -55,6 +65,7 @@ class MessagesPresenter @Inject constructor( private val actionListPresenter: ActionListPresenter, private val networkMonitor: NetworkMonitor, private val snackbarDispatcher: SnackbarDispatcher, + private val messageSummaryFormatter: MessageSummaryFormatter, ) : Presenter { @Composable @@ -145,7 +156,38 @@ class MessagesPresenter @Inject constructor( private fun handleActionReply(targetEvent: TimelineItem.Event, composerState: MessageComposerState) { if (targetEvent.eventId == null) return - val composerMode = MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.eventId, "") + val textContent = messageSummaryFormatter.format(targetEvent) + val attachmentThumbnailInfo = when (targetEvent.content) { + is TimelineItemImageContent -> AttachmentThumbnailInfo( + mediaSource = targetEvent.content.mediaSource, + textContent = targetEvent.content.body, + type = AttachmentThumbnailType.Image, + blurHash = targetEvent.content.blurhash, + ) + is TimelineItemVideoContent -> AttachmentThumbnailInfo( + mediaSource = targetEvent.content.thumbnailSource, + textContent = targetEvent.content.body, + type = AttachmentThumbnailType.Video, + blurHash = targetEvent.content.blurHash, + ) + is TimelineItemFileContent -> AttachmentThumbnailInfo( + mediaSource = targetEvent.content.thumbnailSource, + textContent = targetEvent.content.body, + type = AttachmentThumbnailType.File, + blurHash = null, + ) + is TimelineItemTextBasedContent, + is TimelineItemRedactedContent, + is TimelineItemStateContent, + is TimelineItemEncryptedContent, + is TimelineItemUnknownContent -> null + } + val composerMode = MessageComposerMode.Reply( + senderName = targetEvent.safeSenderName, + eventId = targetEvent.eventId, + attachmentThumbnailInfo = attachmentThumbnailInfo, + defaultContent = textContent, + ) composerState.eventSink( MessageComposerEvents.SetMode(composerMode) ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 579dfeaf5a..05ee38a2ec 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -76,6 +76,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.utils.LogCompositions +import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState import io.element.android.libraries.matrix.api.core.UserId import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.launch @@ -94,23 +95,11 @@ fun MessagesView( modifier: Modifier = Modifier, ) { LogCompositions(tag = "MessagesScreen", msg = "Root") - val coroutineScope = rememberCoroutineScope() var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) } AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments) - val snackbarHostState = remember { SnackbarHostState() } - val snackbarMessageText = state.snackbarMessage?.let { stringResource(it.messageResId) } - if (snackbarMessageText != null) { - SideEffect { - coroutineScope.launch { - snackbarHostState.showSnackbar( - message = snackbarMessageText, - duration = state.snackbarMessage.duration - ) - } - } - } + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) // This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose val localView = LocalView.current diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index 86d957e777..d7222be960 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -16,7 +16,6 @@ package io.element.android.features.messages.impl.actionlist -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -36,19 +35,16 @@ import androidx.compose.material.ListItem import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AddReaction -import androidx.compose.material.icons.outlined.Attachment -import androidx.compose.material.icons.outlined.VideoCameraBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -56,7 +52,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction -import io.element.android.features.messages.impl.timeline.components.blurhash.BlurHashAsyncImage import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent @@ -67,6 +62,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatterImpl import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarSize @@ -75,8 +71,9 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.theme.components.Divider import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet -import io.element.android.libraries.matrix.ui.media.MediaRequestData -import io.element.android.libraries.ui.strings.R as StringR +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -189,70 +186,56 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif Text(body, style = contentStyle, maxLines = 1, overflow = TextOverflow.Ellipsis) } + val context = LocalContext.current + val formatter = remember(context) { MessageSummaryFormatterImpl(context) } + val textContent = remember(event.content) { formatter.format(event) } + when (event.content) { - is TimelineItemTextBasedContent -> content = { ContentForBody(event.content.body) } - is TimelineItemStateContent -> content = { ContentForBody(event.content.body) } - is TimelineItemProfileChangeContent -> content = { ContentForBody(event.content.body) } - is TimelineItemEncryptedContent -> content = { ContentForBody(stringResource(StringR.string.common_unable_to_decrypt)) } - is TimelineItemRedactedContent -> content = { ContentForBody(stringResource(StringR.string.common_message_removed)) } - is TimelineItemUnknownContent -> content = { ContentForBody(stringResource(StringR.string.common_unsupported_event)) } + is TimelineItemTextBasedContent, + is TimelineItemStateContent, + is TimelineItemProfileChangeContent, + is TimelineItemEncryptedContent, + is TimelineItemRedactedContent, + is TimelineItemUnknownContent -> content = { ContentForBody(textContent) } is TimelineItemImageContent -> { icon = { - val mediaRequestData = MediaRequestData( - source = event.content.mediaSource, - kind = MediaRequestData.Kind.Thumbnail(32), - ) - BlurHashAsyncImage( - model = mediaRequestData, - blurHash = event.content.blurhash, - contentDescription = stringResource(StringR.string.common_image), - contentScale = ContentScale.Crop, + AttachmentThumbnail( modifier = imageModifier, + info = AttachmentThumbnailInfo( + mediaSource = event.content.mediaSource, + textContent = textContent, + type = AttachmentThumbnailType.File, + blurHash = event.content.blurhash, + ) ) } content = { ContentForBody(event.content.body) } } is TimelineItemVideoContent -> { icon = { - val thumbnailSource = event.content.thumbnailSource - if (thumbnailSource != null) { - val mediaRequestData = MediaRequestData( - source = event.content.thumbnailSource, - kind = MediaRequestData.Kind.Thumbnail(32), - ) - BlurHashAsyncImage( - model = mediaRequestData, + AttachmentThumbnail( + modifier = imageModifier, + info = AttachmentThumbnailInfo( + mediaSource = event.content.thumbnailSource, + textContent = textContent, + type = AttachmentThumbnailType.Video, blurHash = event.content.blurHash, - contentDescription = stringResource(StringR.string.common_video), - contentScale = ContentScale.Crop, - modifier = imageModifier, ) - } else { - Box( - modifier = imageModifier.background(MaterialTheme.colorScheme.surface), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Outlined.VideoCameraBack, - contentDescription = stringResource(StringR.string.common_video), - ) - } - } + ) } content = { ContentForBody(event.content.body) } } is TimelineItemFileContent -> { icon = { - Box( - modifier = imageModifier.background(MaterialTheme.colorScheme.surface), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Outlined.Attachment, - contentDescription = stringResource(StringR.string.common_file), - modifier = Modifier.rotate(-45f) + AttachmentThumbnail( + modifier = imageModifier, + info = AttachmentThumbnailInfo( + mediaSource = null, + textContent = textContent, + type = AttachmentThumbnailType.File, + blurHash = null ) - } + ) } content = { ContentForBody(event.content.body) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index d80359e88c..807a23f6cc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -84,7 +84,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( sendActionState: MutableState>, ) { suspend { - mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.mimeType, mediaAttachment.compressIfPossible) + mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.info.mimeType, mediaAttachment.compressIfPossible) }.executeResult(sendActionState) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt index 26565a226a..58fea4a4f2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt @@ -20,21 +20,27 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.core.net.toUri import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.media.local.LocalMedia +import io.element.android.features.messages.impl.media.local.MediaInfo +import io.element.android.features.messages.impl.media.local.aFileInfo +import io.element.android.features.messages.impl.media.local.aVideoInfo +import io.element.android.features.messages.impl.media.local.anImageInfo import io.element.android.libraries.architecture.Async -import io.element.android.libraries.core.mimetype.MimeTypes open class AttachmentsPreviewStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( anAttachmentsPreviewState(), + anAttachmentsPreviewState(mediaInfo = aFileInfo()), anAttachmentsPreviewState(sendActionState = Async.Loading()), anAttachmentsPreviewState(sendActionState = Async.Failure(RuntimeException())), ) } -fun anAttachmentsPreviewState(sendActionState: Async = Async.Uninitialized) = AttachmentsPreviewState( +fun anAttachmentsPreviewState( + mediaInfo: MediaInfo = anImageInfo(), + sendActionState: Async = Async.Uninitialized) = AttachmentsPreviewState( attachment = Attachment.Media( - localMedia = LocalMedia("path".toUri(), MimeTypes.Jpeg, "an image", 1000L), + localMedia = LocalMedia("file://path".toUri(), mediaInfo), compressIfPossible = true ), sendActionState = sendActionState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt index a96cd43628..8eca2c3313 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt @@ -121,7 +121,8 @@ private fun AttachmentPreviewContent( Box( modifier = Modifier .fillMaxWidth() - .weight(1f) + .weight(1f), + contentAlignment = Alignment.Center, ) { when (attachment) { is Attachment.Media -> LocalMediaView( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/helper/fileExtensionAndSize.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/helper/fileExtensionAndSize.kt new file mode 100644 index 0000000000..fcf64eb24f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/helper/fileExtensionAndSize.kt @@ -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)") + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt new file mode 100644 index 0000000000..44bff4f5ab --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt @@ -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 = 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 = 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 = 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() + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaFactory.kt index bc2f1a066c..22d03831b5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaFactory.kt @@ -20,33 +20,50 @@ import android.content.Context import android.net.Uri import androidx.core.net.toUri import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter import io.element.android.libraries.androidutils.file.getFileName import io.element.android.libraries.androidutils.file.getFileSize +import io.element.android.libraries.androidutils.file.getMimeType import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.matrix.api.media.MediaFile +import io.element.android.libraries.matrix.api.media.toFile import javax.inject.Inject @ContributesBinding(AppScope::class) class AndroidLocalMediaFactory @Inject constructor( - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + private val fileSizeFormatter: FileSizeFormatter, ) : LocalMediaFactory { - override fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia { - val uri = mediaFile.path().toUri() - return createFromUri(uri, mimeType) + override fun createFromMediaFile(mediaFile: MediaFile, mediaInfo: MediaInfo): LocalMedia { + val uri = mediaFile.toFile().toUri() + return createFromUri( + uri = uri, + mimeType = mediaInfo.mimeType, + name = mediaInfo.name, + formattedFileSize = mediaInfo.formattedFileSize + ) } - override fun createFromUri(uri: Uri, mimeType: String?): LocalMedia { - val resolvedMimeType = mimeType ?: context.contentResolver.getType(uri) ?: MimeTypes.OctetStream - val fileName = context.getFileName(uri) - val fileSize = context.getFileSize(uri) + override fun createFromUri( + uri: Uri, + mimeType: String?, + name: String?, + formattedFileSize: String? + ): LocalMedia { + val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream + val fileName = name ?: context.getFileName(uri) ?: "" + val fileSize = formattedFileSize ?: fileSizeFormatter.format(context.getFileSize(uri)) return LocalMedia( uri = uri, - mimeType = resolvedMimeType, - name = fileName, - size = fileSize + info = MediaInfo( + mimeType = resolvedMimeType, + name = fileName, + formattedFileSize = fileSize + ) ) } } + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMedia.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMedia.kt index 8305c8eee7..549842428a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMedia.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMedia.kt @@ -19,22 +19,11 @@ package io.element.android.features.messages.impl.media.local import android.net.Uri import android.os.Parcelable import androidx.compose.runtime.Immutable -import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize @Immutable data class LocalMedia( val uri: Uri, - val mimeType: String, - val name: String?, - val size: Long, -) : Parcelable { - - /** - * This tries to convert the uri to a file if applicable, otherwise keep it as uri. - */ - @IgnoredOnParcel val model: Any by lazy { - UriToFileMapper.map(uri) ?: uri - } -} + val info: MediaInfo, +) : Parcelable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt new file mode 100644 index 0000000000..f35af36057 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt @@ -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 + + /** + * 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 + + /** + * 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 +} + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaFactory.kt index 09c44f4fba..36852a5a80 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaFactory.kt @@ -22,13 +22,21 @@ import io.element.android.libraries.matrix.api.media.MediaFile interface LocalMediaFactory { /** - * This method will create a [LocalMedia] with the given [MediaFile] and [mimeType]. + * This method will create a [LocalMedia] with the given [MediaFile] and [MediaInfo]. */ - fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia + fun createFromMediaFile( + mediaFile: MediaFile, + mediaInfo: MediaInfo, + ): LocalMedia /** - * This method will create a [LocalMedia] with the given [uri] and [mimeType] - * If the [mimeType] is null, it'll try to read it from the content. + * This method will create a [LocalMedia] with the given mimeType, name and formattedFileSize + * If any of those params are null, it'll try to read them from the content. */ - fun createFromUri(uri: Uri, mimeType: String?): LocalMedia + fun createFromUri( + uri: Uri, + mimeType: String?, + name: String?, + formattedFileSize: String? + ): LocalMedia } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt index 142fb57102..13f0568511 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt @@ -17,18 +17,37 @@ package io.element.android.features.messages.impl.media.local import android.annotation.SuppressLint +import android.net.Uri import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Attachment +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.media3.common.MediaItem @@ -36,6 +55,7 @@ import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView +import io.element.android.features.messages.impl.media.helper.formatFileExtensionAndSize import io.element.android.features.messages.impl.media.local.exoplayer.ExoPlayerWrapper import io.element.android.features.messages.impl.media.local.pdf.PdfViewer import io.element.android.features.messages.impl.media.local.pdf.rememberPdfViewerState @@ -43,6 +63,9 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.designsystem.R +import io.element.android.libraries.designsystem.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.ZoomableState @@ -55,39 +78,45 @@ import me.saket.telephoto.zoomable.rememberZoomableState fun LocalMediaView( localMedia: LocalMedia?, modifier: Modifier = Modifier, - mimeType: String? = localMedia?.mimeType, - onReady: () -> Unit = {}, + localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(), + mediaInfo: MediaInfo? = localMedia?.info, ) { val zoomableState = rememberZoomableState( zoomSpec = ZoomSpec(maxZoomFactor = 5f) ) + val mimeType = mediaInfo?.mimeType when { mimeType.isMimeTypeImage() -> MediaImageView( + localMediaViewState = localMediaViewState, localMedia = localMedia, zoomableState = zoomableState, - onReady = onReady, modifier = modifier ) mimeType.isMimeTypeVideo() -> MediaVideoView( + localMediaViewState = localMediaViewState, localMedia = localMedia, - onReady = onReady, modifier = modifier ) mimeType == MimeTypes.Pdf -> MediaPDFView( + localMediaViewState = localMediaViewState, localMedia = localMedia, zoomableState = zoomableState, - onReady = onReady, modifier = modifier ) - else -> Unit + else -> MediaFileView( + localMediaViewState = localMediaViewState, + uri = localMedia?.uri, + info = mediaInfo, + modifier = modifier + ) } } @Composable private fun MediaImageView( + localMediaViewState: LocalMediaViewState, localMedia: LocalMedia?, zoomableState: ZoomableState, - onReady: () -> Unit, modifier: Modifier = Modifier, ) { if (LocalInspectionMode.current) { @@ -98,15 +127,11 @@ private fun MediaImageView( ) } else { val zoomableImageState = rememberZoomableImageState(zoomableState) - LaunchedEffect(zoomableImageState.isImageDisplayed) { - if (zoomableImageState.isImageDisplayed) { - onReady() - } - } + localMediaViewState.isReady = zoomableImageState.isImageDisplayed ZoomableAsyncImage( modifier = modifier.fillMaxSize(), state = zoomableImageState, - model = localMedia?.model, + model = localMedia?.uri, contentDescription = "Image", contentScale = ContentScale.Fit, ) @@ -116,14 +141,14 @@ private fun MediaImageView( @UnstableApi @Composable fun MediaVideoView( + localMediaViewState: LocalMediaViewState, localMedia: LocalMedia?, - onReady: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current val playerListener = object : Player.Listener { override fun onRenderedFirstFrame() { - onReady() + localMediaViewState.isReady = true } } val exoPlayer = remember { @@ -170,19 +195,64 @@ fun MediaVideoView( @Composable fun MediaPDFView( + localMediaViewState: LocalMediaViewState, localMedia: LocalMedia?, zoomableState: ZoomableState, - onReady: () -> Unit, modifier: Modifier = Modifier, ) { val pdfViewerState = rememberPdfViewerState( - model = localMedia?.model, + model = localMedia?.uri, zoomableState = zoomableState ) - LaunchedEffect(pdfViewerState.isLoaded) { - if (pdfViewerState.isLoaded) { - onReady() - } - } + localMediaViewState.isReady = pdfViewerState.isLoaded PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier) } + +@Composable +fun MediaFileView( + localMediaViewState: LocalMediaViewState, + uri: Uri?, + info: MediaInfo?, + modifier: Modifier = Modifier, +) { + localMediaViewState.isReady = uri != null + Box(modifier = modifier.padding(horizontal = 8.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onBackground), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Attachment, + contentDescription = null, + tint = MaterialTheme.colorScheme.background, + modifier = Modifier + .size(32.dp) + .rotate(-45f), + ) + } + if (info != null) { + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = info.name, + maxLines = 2, + fontSize = 16.sp, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + color = ElementTheme.colors.gray1400 + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatFileExtensionAndSize(info.name, info.formattedFileSize), + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = ElementTheme.colors.gray1400 + ) + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaViewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaViewState.kt new file mode 100644 index 0000000000..e009c3f6cc --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaViewState.kt @@ -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() + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/MediaInfo.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/MediaInfo.kt new file mode 100644 index 0000000000..57cd788bb8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/MediaInfo.kt @@ -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" +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/UriToFileMapper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/UriToFileMapper.kt deleted file mode 100644 index d27b667883..0000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/UriToFileMapper.kt +++ /dev/null @@ -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 - } -} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfRendererManager.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfRendererManager.kt index 21eeaa652b..8f6c507eb5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfRendererManager.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfRendererManager.kt @@ -43,11 +43,11 @@ class PdfRendererManager( mutex.withLock { withContext(Dispatchers.IO) { pdfRenderer = PdfRenderer(parcelFileDescriptor).apply { - (0 until pageCount).map { pageIndex -> - PdfPage(width, pageIndex, mutex, this, coroutineScope) - }.also { - mutablePdfPages.value = it - } + // Preload just 3 pages so we can render faster + val firstPages = loadPages(from = 0, to = 3) + mutablePdfPages.value = firstPages + val nextPages = loadPages(from = 3, to = pageCount) + mutablePdfPages.value = firstPages + nextPages } } } @@ -65,4 +65,10 @@ class PdfRendererManager( } } } + + private fun PdfRenderer.loadPages(from: Int, to: Int): List { + return (from until minOf(to, pageCount)).map { pageIndex -> + PdfPage(width, pageIndex, mutex, this, coroutineScope) + } + } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt index b0bbad5ec2..b680ee58c9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt @@ -17,6 +17,9 @@ package io.element.android.features.messages.impl.media.viewer sealed interface MediaViewerEvents { + object SaveOnDisk: MediaViewerEvents + object Share: MediaViewerEvents + object OpenWith: MediaViewerEvents object RetryLoading : MediaViewerEvents object ClearLoadingError : MediaViewerEvents } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerNode.kt index 247a86263f..32bf2e39ac 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerNode.kt @@ -24,6 +24,7 @@ import com.bumble.appyx.core.plugin.Plugin import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.messages.impl.media.local.MediaInfo import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.inputs import io.element.android.libraries.designsystem.theme.ForcedDarkElementTheme @@ -38,10 +39,9 @@ class MediaViewerNode @AssistedInject constructor( ) : Node(buildContext, plugins = plugins) { data class Inputs( - val name: String, + val mediaInfo: MediaInfo, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, - val mimeType: String? ) : NodeInputs private val inputs: Inputs = inputs() @@ -54,7 +54,8 @@ class MediaViewerNode @AssistedInject constructor( val state = presenter.present() MediaViewerView( state = state, - modifier = modifier + modifier = modifier, + onBackPressed = this::navigateUp ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt index fb563461c5..88bf7c91c2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt @@ -16,6 +16,7 @@ package io.element.android.features.messages.impl.media.viewer +import android.content.ActivityNotFoundException import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState @@ -28,18 +29,26 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.element.android.features.messages.impl.media.local.LocalMedia +import io.element.android.features.messages.impl.media.local.LocalMediaActions import io.element.android.features.messages.impl.media.local.LocalMediaFactory import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.SnackbarMessage +import io.element.android.libraries.designsystem.utils.handleSnackbarMessage import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaFile import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import io.element.android.libraries.androidutils.R as UtilsR +import io.element.android.libraries.ui.strings.R as StringR class MediaViewerPresenter @AssistedInject constructor( @Assisted private val inputs: MediaViewerNode.Inputs, private val localMediaFactory: LocalMediaFactory, private val mediaLoader: MatrixMediaLoader, + private val localMediaActions: LocalMediaActions, + private val snackbarDispatcher: SnackbarDispatcher, ) : Presenter { @AssistedFactory @@ -57,6 +66,8 @@ class MediaViewerPresenter @AssistedInject constructor( val localMedia: MutableState> = remember { mutableStateOf(Async.Uninitialized) } + val snackbarMessage = handleSnackbarMessage(snackbarDispatcher) + localMediaActions.Configure() DisposableEffect(loadMediaTrigger) { coroutineScope.downloadMedia(mediaFile, localMedia) onDispose { @@ -68,29 +79,84 @@ class MediaViewerPresenter @AssistedInject constructor( when (mediaViewerEvents) { MediaViewerEvents.RetryLoading -> loadMediaTrigger++ MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized + MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value) + MediaViewerEvents.Share -> coroutineScope.share(localMedia.value) + MediaViewerEvents.OpenWith -> coroutineScope.open(localMedia.value) } } return MediaViewerState( - name = inputs.name, - mimeType = inputs.mimeType, + mediaInfo = inputs.mediaInfo, thumbnailSource = inputs.thumbnailSource, downloadedMedia = localMedia.value, + snackbarMessage = snackbarMessage, eventSink = ::handleEvents ) } private fun CoroutineScope.downloadMedia(mediaFile: MutableState, localMedia: MutableState>) = launch { localMedia.value = Async.Loading() - mediaLoader.downloadMediaFile(inputs.mediaSource, inputs.mimeType) + mediaLoader.downloadMediaFile( + source = inputs.mediaSource, + mimeType = inputs.mediaInfo.mimeType, + body = inputs.mediaInfo.name + ) .onSuccess { mediaFile.value = it - }.mapCatching { - localMediaFactory.createFromMediaFile(it, inputs.mimeType) + }.mapCatching { mediaFile -> + localMediaFactory.createFromMediaFile( + mediaFile = mediaFile, + mediaInfo = inputs.mediaInfo + ) }.onSuccess { localMedia.value = Async.Success(it) }.onFailure { localMedia.value = Async.Failure(it) } } + + private fun CoroutineScope.saveOnDisk(localMedia: Async) = 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) = 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) = 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 + } + } } + + + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerState.kt index c42263dd3e..18375746c5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerState.kt @@ -17,13 +17,15 @@ package io.element.android.features.messages.impl.media.viewer import io.element.android.features.messages.impl.media.local.LocalMedia +import io.element.android.features.messages.impl.media.local.MediaInfo import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.matrix.api.media.MediaSource data class MediaViewerState( - val name: String, - val mimeType: String?, + val mediaInfo: MediaInfo, val thumbnailSource: MediaSource?, val downloadedMedia: Async, + val snackbarMessage: SnackbarMessage?, val eventSink: (MediaViewerEvents) -> Unit, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerStateProvider.kt index 6c54eb3b05..786ec984b7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerStateProvider.kt @@ -18,8 +18,12 @@ package io.element.android.features.messages.impl.media.viewer import android.net.Uri import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import androidx.media3.common.MimeTypes import io.element.android.features.messages.impl.media.local.LocalMedia +import io.element.android.features.messages.impl.media.local.MediaInfo +import io.element.android.features.messages.impl.media.local.aFileInfo +import io.element.android.features.messages.impl.media.local.aPdfInfo +import io.element.android.features.messages.impl.media.local.aVideoInfo +import io.element.android.features.messages.impl.media.local.anImageInfo import io.element.android.libraries.architecture.Async open class MediaViewerStateProvider : PreviewParameterProvider { @@ -30,24 +34,41 @@ open class MediaViewerStateProvider : PreviewParameterProvider aMediaViewerState(Async.Failure(IllegalStateException())), aMediaViewerState( Async.Success( - LocalMedia( - Uri.EMPTY, MimeTypes.IMAGE_JPEG, "an image file", 100L - ) + LocalMedia(Uri.EMPTY, anImageInfo()) ), + anImageInfo(), ), aMediaViewerState( Async.Success( - LocalMedia( - Uri.EMPTY, MimeTypes.VIDEO_MP4, "a video file", 100L - ) + LocalMedia(Uri.EMPTY, aVideoInfo()) ), + aVideoInfo(), + ), + aMediaViewerState( + Async.Success( + LocalMedia(Uri.EMPTY, aPdfInfo()) + ), + aPdfInfo(), + ), + aMediaViewerState( + Async.Loading(), + aFileInfo(), + ), + aMediaViewerState( + Async.Success( + LocalMedia(Uri.EMPTY, aFileInfo()) + ), + aFileInfo(), ) ) } -fun aMediaViewerState(downloadedMedia: Async = Async.Uninitialized) = MediaViewerState( - name = "A media", - mimeType = MimeTypes.IMAGE_JPEG, +fun aMediaViewerState( + downloadedMedia: Async = Async.Uninitialized, + mediaInfo: MediaInfo = anImageInfo(), +) = MediaViewerState( + mediaInfo = mediaInfo, thumbnailSource = null, downloadedMedia = downloadedMedia, + snackbarMessage = null ) {} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt index ae598688d1..afbb9bb331 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt @@ -14,6 +14,7 @@ * limitations under the License. */ +@file:OptIn(ExperimentalMaterial3Api::class) package io.element.android.features.messages.impl.media.viewer @@ -21,8 +22,21 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.OpenInNew +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -32,18 +46,25 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp import coil.compose.AsyncImage +import io.element.android.features.messages.impl.media.local.LocalMedia import io.element.android.features.messages.impl.media.local.LocalMediaView +import io.element.android.features.messages.impl.media.local.rememberLocalMediaViewState import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.isLoading +import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.dialogs.RetryDialog -import io.element.android.libraries.designsystem.modifiers.roundedBackground import io.element.android.libraries.designsystem.preview.ElementPreviewDark -import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.ui.media.MediaRequestData import kotlinx.coroutines.delay @@ -52,6 +73,7 @@ import io.element.android.libraries.ui.strings.R as StringR @Composable fun MediaViewerView( state: MediaViewerState, + onBackPressed: () -> Unit, modifier: Modifier = Modifier, ) { @@ -63,61 +85,132 @@ fun MediaViewerView( state.eventSink(MediaViewerEvents.ClearLoadingError) } - var showProgress by remember { - mutableStateOf(false) - } + val localMediaViewState = rememberLocalMediaViewState() + val showThumbnail = !localMediaViewState.isReady + val showProgress = rememberShowProgress(state.downloadedMedia) + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) - // Trick to avoid showing progress indicator if the media is already on disk. - // When sdk will expose download progress we'll be able to remove this. - LaunchedEffect(state.downloadedMedia) { - showProgress = false - delay(100) - if (state.downloadedMedia.isLoading()) { - showProgress = true - } - } - - var showThumbnail by remember { - mutableStateOf(true) - } - - fun onMediaReady() { - showThumbnail = false - } - - Scaffold(modifier) { - Box( + Scaffold( + modifier, + topBar = { + MediaViewerTopBar( + actionsEnabled = state.downloadedMedia is Async.Success, + onBackPressed = onBackPressed, + eventSink = state.eventSink + ) + }, + snackbarHost = { + SnackbarHost(snackbarHostState) { data -> + Snackbar( + snackbarData = data, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.primary + ) + } + }, + ) { + Column( modifier = Modifier .fillMaxSize() .padding(it), - contentAlignment = Alignment.Center ) { - if (state.downloadedMedia is Async.Failure) { - ErrorView( - errorMessage = stringResource(id = StringR.string.error_unknown), - onRetry = ::onRetry, - onDismiss = ::onDismissError + if (showProgress) { + LinearProgressIndicator( + Modifier + .fillMaxWidth() + .height(2.dp) + ) + } else { + Spacer(Modifier.height(2.dp)) + } + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (state.downloadedMedia is Async.Failure) { + ErrorView( + errorMessage = stringResource(id = StringR.string.error_unknown), + onRetry = ::onRetry, + onDismiss = ::onDismissError + ) + } + LocalMediaView( + localMediaViewState = localMediaViewState, + localMedia = state.downloadedMedia.dataOrNull(), + mediaInfo = state.mediaInfo, + ) + ThumbnailView( + thumbnailSource = state.thumbnailSource, + showThumbnail = showThumbnail, ) } - LocalMediaView( - localMedia = state.downloadedMedia.dataOrNull(), - mimeType = state.mimeType, - onReady = ::onMediaReady - ) - ThumbnailView( - thumbnailSource = state.thumbnailSource, - showThumbnail = showThumbnail, - showProgress = showProgress, - ) } } } +@Composable +private fun rememberShowProgress(downloadedMedia: Async): Boolean { + var showProgress by remember { + mutableStateOf(false) + } + if (LocalInspectionMode.current) { + showProgress = downloadedMedia.isLoading() + } else { + // Trick to avoid showing progress indicator if the media is already on disk. + // When sdk will expose download progress we'll be able to remove this. + LaunchedEffect(downloadedMedia) { + showProgress = false + delay(100) + if (downloadedMedia.isLoading()) { + showProgress = true + } + } + } + return showProgress +} + +@Composable +private fun MediaViewerTopBar( + actionsEnabled: Boolean, + onBackPressed: () -> Unit, + eventSink: (MediaViewerEvents) -> Unit, +) { + TopAppBar( + title = {}, + navigationIcon = { BackButton(onClick = onBackPressed) }, + actions = { + IconButton( + enabled = actionsEnabled, + onClick = { + eventSink(MediaViewerEvents.OpenWith) + }, + ) { + Icon(imageVector = Icons.Default.OpenInNew, contentDescription = stringResource(id = StringR.string.action_open_with)) + } + IconButton( + enabled = actionsEnabled, + onClick = { + eventSink(MediaViewerEvents.SaveOnDisk) + }, + ) { + Icon(imageVector = Icons.Default.Download, contentDescription = stringResource(id = StringR.string.action_save)) + } + IconButton( + enabled = actionsEnabled, + onClick = { + eventSink(MediaViewerEvents.Share) + }, + ) { + Icon(imageVector = Icons.Default.Share, contentDescription = stringResource(id = StringR.string.action_share)) + } + } + ) +} + @Composable private fun ThumbnailView( thumbnailSource: MediaSource?, showThumbnail: Boolean, - showProgress: Boolean, ) { AnimatedVisibility( visible = showThumbnail, @@ -139,14 +232,6 @@ private fun ThumbnailView( contentScale = ContentScale.Fit, contentDescription = null, ) - if (showProgress) { - Box( - modifier = Modifier.roundedBackground(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } } } } @@ -175,5 +260,6 @@ fun MediaViewerViewDarkPreview(@PreviewParameter(MediaViewerStateProvider::class private fun ContentToPreview(state: MediaViewerState) { MediaViewerView( state = state, + onBackPressed = {} ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index ae975f4ddb..7c5e2d5705 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -192,7 +192,7 @@ class MessageComposerPresenter @Inject constructor( is Attachment.Media -> { sendMedia( uri = attachment.localMedia.uri, - mimeType = attachment.localMedia.mimeType, + mimeType = attachment.localMedia.info.mimeType, attachmentState = attachmentState ) } @@ -210,12 +210,17 @@ class MessageComposerPresenter @Inject constructor( attachmentsState.value = AttachmentsState.None return } - val localMedia = localMediaFactory.createFromUri(uri, mimeType) + val localMedia = localMediaFactory.createFromUri( + uri = uri, + mimeType = mimeType, + name = null, + formattedFileSize = null + ) val mediaAttachment = Attachment.Media(localMedia, compressIfPossible) val isPreviewable = when { - MimeTypes.isImage(localMedia.mimeType) -> true - MimeTypes.isVideo(localMedia.mimeType) -> true - MimeTypes.isAudio(localMedia.mimeType) -> true + MimeTypes.isImage(localMedia.info.mimeType) -> true + MimeTypes.isVideo(localMedia.info.mimeType) -> true + MimeTypes.isAudio(localMedia.info.mimeType) -> true else -> false } attachmentsState.value = if (isPreviewable) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 2cee290871..7e23657974 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlin.random.Random @@ -96,6 +97,7 @@ internal fun aTimelineItemEvent( content: TimelineItemEventContent = aTimelineItemTextContent(), groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, sendState: EventSendState = EventSendState.Sent(eventId), + inReplyTo: InReplyTo? = null, ): TimelineItem.Event { return TimelineItem.Event( id = eventId.value, @@ -113,5 +115,6 @@ internal fun aTimelineItemEvent( senderDisplayName = "sender", groupPosition = groupPosition, sendState = sendState, + inReplyTo = inReplyTo, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index f5ebdcc796..f8717517f4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -17,30 +17,16 @@ package io.element.android.features.messages.impl.timeline import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material3.MaterialTheme @@ -55,39 +41,24 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.LastBaseline import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex import io.element.android.features.messages.impl.R -import io.element.android.features.messages.impl.timeline.components.MessageEventBubble -import io.element.android.features.messages.impl.timeline.components.MessageStateEventContainer -import io.element.android.features.messages.impl.timeline.components.TimelineEventTimestampView -import io.element.android.features.messages.impl.timeline.components.TimelineItemReactionsView -import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView +import io.element.android.features.messages.impl.timeline.components.TimelineItemEventRow +import io.element.android.features.messages.impl.timeline.components.TimelineItemStateEventRow +import io.element.android.features.messages.impl.timeline.components.TimelineItemVirtualRow import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView -import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView -import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator import io.element.android.features.messages.impl.timeline.model.TimelineItem -import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent -import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel -import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingModel -import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding -import io.element.android.libraries.designsystem.components.avatar.Avatar -import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight -import io.element.android.libraries.designsystem.theme.LocalColors import io.element.android.libraries.designsystem.theme.components.FloatingActionButton import io.element.android.libraries.designsystem.theme.components.Icon -import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.distinctUntilChanged @@ -101,12 +72,16 @@ fun TimelineView( onMessageClicked: (TimelineItem.Event) -> Unit = {}, onMessageLongClicked: (TimelineItem.Event) -> Unit = {}, ) { - fun onReachedLoadMore() { state.eventSink(TimelineEvents.LoadMore) } val lazyListState = rememberLazyListState() + + fun inReplyToClicked(eventId: EventId) { + // TODO implement this logic once we have support to 'jump to event X' in sliding sync + } + Box(modifier = modifier) { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -124,6 +99,7 @@ fun TimelineView( onClick = onMessageClicked, onLongClick = onMessageLongClicked, onUserDataClick = onUserDataClicked, + inReplyToClick = ::inReplyToClicked, ) if (index == state.timelineItems.lastIndex) { onReachedLoadMore() @@ -146,6 +122,7 @@ fun TimelineItemRow( onUserDataClick: (UserId) -> Unit, onClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit, + inReplyToClick: (EventId) -> Unit, modifier: Modifier = Modifier ) { when (timelineItem) { @@ -179,6 +156,7 @@ fun TimelineItemRow( onClick = ::onClick, onLongClick = ::onLongClick, onUserDataClick = onUserDataClick, + inReplyToClick = inReplyToClick, modifier = modifier, ) } @@ -209,6 +187,7 @@ fun TimelineItemRow( highlightedItem = highlightedItem, onClick = onClick, onLongClick = onLongClick, + inReplyToClick = inReplyToClick, onUserDataClick = onUserDataClick, ) } @@ -219,208 +198,6 @@ fun TimelineItemRow( } } -@Composable -fun TimelineItemVirtualRow( - virtual: TimelineItem.Virtual, - modifier: Modifier = Modifier -) { - when (virtual.model) { - is TimelineItemLoadingModel -> TimelineLoadingMoreIndicator(modifier) - is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier) - else -> return - } -} - -@Composable -fun TimelineItemEventRow( - event: TimelineItem.Event, - isHighlighted: Boolean, - onClick: () -> Unit, - onLongClick: () -> Unit, - onUserDataClick: (UserId) -> Unit, - modifier: Modifier = Modifier -) { - val interactionSource = remember { MutableInteractionSource() } - - fun onUserDataClicked() { - onUserDataClick(event.senderId) - } - - val (parentAlignment, contentAlignment) = if (event.isMine) { - Pair(Alignment.CenterEnd, Alignment.End) - } else { - Pair(Alignment.CenterStart, Alignment.Start) - } - - Box( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight(), - contentAlignment = parentAlignment - ) { - Row { - if (!event.isMine) { - Spacer(modifier = Modifier.width(4.dp)) - } - Column(horizontalAlignment = contentAlignment) { - if (event.showSenderInformation) { - MessageSenderInformation( - event.safeSenderName, - event.senderAvatar, - Modifier - .zIndex(1f) - .offset(y = 12.dp) - .clickable(onClick = ::onUserDataClicked) - ) - } - val bubbleState = BubbleState( - groupPosition = event.groupPosition, - isMine = event.isMine, - isHighlighted = isHighlighted, - ) - MessageEventBubble( - state = bubbleState, - interactionSource = interactionSource, - onClick = onClick, - onLongClick = onLongClick, - modifier = Modifier - .zIndex(-1f) - .widthIn(max = 320.dp) - ) { - MessageEventBubbleContent( - event = event, - interactionSource = interactionSource, - onMessageClick = onClick, - onMessageLongClick = onLongClick - ) - } - TimelineItemReactionsView( - reactionsState = event.reactionsState, - modifier = Modifier - .zIndex(1f) - .offset(x = if (event.isMine) 0.dp else 20.dp, y = -(4.dp)) - ) - } - if (event.isMine) { - Spacer(modifier = Modifier.width(16.dp)) - } - } - } - if (event.groupPosition.isNew()) { - Spacer(modifier = modifier.height(8.dp)) - } else { - Spacer(modifier = modifier.height(2.dp)) - } -} - -@Composable -fun TimelineItemStateEventRow( - event: TimelineItem.Event, - isHighlighted: Boolean, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier -) { - val interactionSource = remember { MutableInteractionSource() } - Box( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight(), - contentAlignment = Alignment.Center - ) { - MessageStateEventContainer( - isHighlighted = isHighlighted, - interactionSource = interactionSource, - onClick = onClick, - onLongClick = onLongClick, - modifier = Modifier - .zIndex(-1f) - .widthIn(max = 320.dp) - ) { - TimelineItemEventContentView( - content = event.content, - interactionSource = interactionSource, - onClick = onClick, - onLongClick = onLongClick, - modifier = Modifier.defaultTimelineContentPadding() - ) - } - } -} - -@Composable -fun MessageEventBubbleContent( - event: TimelineItem.Event, - interactionSource: MutableInteractionSource, - onMessageClick: () -> Unit, - onMessageLongClick: () -> Unit, - modifier: Modifier = Modifier -) { - val showTimestampWithOverlay = event.content is TimelineItemImageContent || event.content is TimelineItemVideoContent - - @Composable - fun ContentView( - modifier: Modifier = Modifier - ) { - TimelineItemEventContentView( - content = event.content, - interactionSource = interactionSource, - onClick = onMessageClick, - onLongClick = onMessageLongClick, - modifier = modifier, - ) - } - - if (showTimestampWithOverlay) { - Box(modifier.wrapContentSize()) { - ContentView() - Box( - modifier = Modifier - .padding(horizontal = 4.dp, vertical = 4.dp) - .background(LocalColors.current.gray300, RoundedCornerShape(10.0.dp)) - .align(Alignment.BottomEnd) - ) { - TimelineEventTimestampView( - event = event, - onClick = onMessageClick, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) - ) - } - } - } else { - Column { - ContentView(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp)) - TimelineEventTimestampView( - event = event, - onClick = onMessageClick, - modifier = Modifier - .align(Alignment.End) - .padding(horizontal = 8.dp, vertical = 2.dp) - ) - } - } -} - -@Composable -private fun MessageSenderInformation( - sender: String, - senderAvatar: AvatarData?, - modifier: Modifier = Modifier -) { - Row(modifier = modifier) { - if (senderAvatar != null) { - Avatar(senderAvatar) - Spacer(modifier = Modifier.width(4.dp)) - } - Text( - text = sender, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .alignBy(LastBaseline) - ) - } -} - @Composable internal fun BoxScope.TimelineScrollHelper( lazyListState: LazyListState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt new file mode 100644 index 0000000000..25f5ff2399 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -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 + } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt new file mode 100644 index 0000000000..7b0a16b9a8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt @@ -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() + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt new file mode 100644 index 0000000000..13a6610ffe --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt @@ -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 + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt index 36aaa27be3..b785734296 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt @@ -20,8 +20,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Attachment @@ -61,10 +62,13 @@ fun TimelineItemFileView( Icon( imageVector = Icons.Outlined.Attachment, contentDescription = "OpenFile", - modifier = Modifier.size(16.dp).rotate(-45f), + modifier = Modifier + .size(16.dp) + .rotate(-45f), ) } - Column(modifier = Modifier.padding(horizontal = 8.dp),) { + Spacer(Modifier.width(8.dp)) + Column { Text( text = content.body, maxLines = 2, @@ -74,6 +78,9 @@ fun TimelineItemFileView( Text( text = content.fileExtensionAndSize, color = MaterialTheme.colorScheme.secondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt index f2cb2b2b92..566e899a36 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt @@ -22,20 +22,23 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter -import io.element.android.features.messages.impl.timeline.components.blurhash.BlurHashAsyncImage import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider +import io.element.android.libraries.designsystem.components.BlurHashAsyncImage import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.matrix.ui.media.MediaRequestData +import kotlin.math.max @Composable fun TimelineItemImageView( content: TimelineItemImageContent, modifier: Modifier = Modifier, ) { + // TODO place this value somewhere else? + val minHeight = max(100, content.height ?: 0) TimelineItemAspectRatioBox( - height = content.height, + height = minHeight, aspectRatio = content.aspectRatio, modifier = modifier ) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index 883f7b30f3..aa024e1033 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -29,9 +29,9 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter -import io.element.android.features.messages.impl.timeline.components.blurhash.BlurHashAsyncImage import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider +import io.element.android.libraries.designsystem.components.BlurHashAsyncImage import io.element.android.libraries.designsystem.modifiers.roundedBackground import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index cc8082be62..8941878334 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter import io.element.android.features.messages.impl.timeline.util.toHtmlDocument +import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType @@ -51,11 +52,12 @@ class TimelineItemContentMessageFactory @Inject constructor( TimelineItemImageContent( body = messageType.body, mediaSource = messageType.source, - mimeType = messageType.info?.mimetype, + mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, blurhash = messageType.info?.blurhash, width = messageType.info?.width?.toInt(), height = messageType.info?.height?.toInt(), - aspectRatio = aspectRatio + aspectRatio = aspectRatio, + formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0) ) } is VideoMessageType -> { @@ -64,22 +66,21 @@ class TimelineItemContentMessageFactory @Inject constructor( body = messageType.body, thumbnailSource = messageType.info?.thumbnailSource, videoSource = messageType.source, - mimeType = messageType.info?.mimetype, + mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, width = messageType.info?.width?.toInt(), height = messageType.info?.height?.toInt(), duration = messageType.info?.duration ?: 0L, blurHash = messageType.info?.blurhash, - aspectRatio = aspectRatio + aspectRatio = aspectRatio, + formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0) ) } is FileMessageType -> TimelineItemFileContent( body = messageType.body, thumbnailSource = messageType.info?.thumbnailSource, fileSource = messageType.source, - mimeType = messageType.info?.mimetype, - formattedFileSize = messageType.info?.size?.let { - fileSizeFormatter.format(it) - }, + mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, + formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0) ) is NoticeMessageType -> TimelineItemNoticeContent( body = messageType.body, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt index 87792edf1d..ecd85a213a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -81,6 +81,7 @@ class TimelineItemEventFactory @Inject constructor( groupPosition = groupPosition, reactionsState = currentTimelineItem.computeReactionsState(), sendState = currentTimelineItem.event.localSendState ?: EventSendState.NotSentYet, + inReplyTo = currentTimelineItem.event.inReplyTo(), ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt index c004901232..5a5f382a54 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import kotlinx.collections.immutable.ImmutableList @Immutable @@ -59,6 +60,7 @@ sealed interface TimelineItem { val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, val reactionsState: TimelineItemReactions, val sendState: EventSendState, + val inReplyTo: InReplyTo?, ) : TimelineItem { val showSenderInformation = groupPosition.isNew() && !isMine diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt index 9307cf8a67..197bae2dda 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt @@ -16,23 +16,17 @@ package io.element.android.features.messages.impl.timeline.model.event +import io.element.android.features.messages.impl.media.helper.formatFileExtensionAndSize import io.element.android.libraries.matrix.api.media.MediaSource data class TimelineItemFileContent( val body: String, val fileSource: MediaSource, val thumbnailSource: MediaSource?, - val formattedFileSize: String?, - val mimeType: String?, + val formattedFileSize: String, + val mimeType: String, ) : TimelineItemEventContent { override val type: String = "TimelineItemFileContent" - private val fileExtension = body.substringAfterLast('.', "").uppercase() - val fileExtensionAndSize = buildString { - append(fileExtension) - if (formattedFileSize != null) { - append(' ') - append("($formattedFileSize)") - } - } + val fileExtensionAndSize = formatFileExtensionAndSize(body, formattedFileSize) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt index 850dc9782c..18be8d404d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt @@ -21,7 +21,8 @@ import io.element.android.libraries.matrix.api.media.MediaSource data class TimelineItemImageContent( val body: String, val mediaSource: MediaSource, - val mimeType: String?, + val formattedFileSize: String, + val mimeType: String, val blurhash: String?, val width: Int?, val height: Int?, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt index 97bbf6ed41..66b35a07d3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt @@ -36,5 +36,6 @@ fun aTimelineItemImageContent() = TimelineItemImageContent( blurhash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr", width = null, height = 300, - aspectRatio = 0.5f + aspectRatio = 0.5f, + formattedFileSize = "4MB" ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt index 41fba2a29f..1432ebbda0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt @@ -27,7 +27,8 @@ data class TimelineItemVideoContent( val blurHash: String?, val height: Int?, val width: Int?, - val mimeType: String?, + val mimeType: String, + val formattedFileSize: String, ) : TimelineItemEventContent { override val type: String = "TimelineItemImageContent" } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt index 9751bb4067..937f35b349 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt @@ -17,6 +17,7 @@ package io.element.android.features.messages.impl.timeline.model.event import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.media.MediaSource open class TimelineItemVideoContentProvider : PreviewParameterProvider { @@ -37,5 +38,6 @@ fun aTimelineItemVideoContent() = TimelineItemVideoContent( videoSource = MediaSource(""), height = 300, width = 150, - mimeType = null + mimeType = MimeTypes.Mp4, + formattedFileSize = "14MB" ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatter.kt new file mode 100644 index 0000000000..241b1282a0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatter.kt @@ -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 +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt new file mode 100644 index 0000000000..b924a9e7b4 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt @@ -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) + } + } +} diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml index 361e80a4de..d94f32a88f 100644 --- a/features/messages/impl/src/main/res/values/localazy.xml +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -9,5 +9,6 @@ "Record a video" "Attachment" "Photo & Video Library" + "Could not retrieve user details" "Failed processing media to upload, please try again." \ No newline at end of file diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index 28765cc110..cb1c923397 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -29,13 +29,21 @@ import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatterImpl import io.element.android.features.messages.media.FakeLocalMediaFactory +import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.room.FakeMatrixRoom @@ -105,6 +113,105 @@ class MessagesPresenterTest { } } + @Test + fun `present - handle action reply to an event with no id does nothing`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null))) + skipItems(1) + // Otherwise we would have some extra items here + ensureAllEventsConsumed() + } + } + + @Test + fun `present - handle action reply to an image media message`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + val mediaMessage = aMessageEvent( + content = TimelineItemImageContent( + body = "image.jpg", + mediaSource = MediaSource(AN_AVATAR_URL), + mimeType = MimeTypes.Jpeg, + blurhash = null, + width = 20, + height = 20, + aspectRatio = 1.0f, + ) + ) + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage)) + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) + val replyMode = finalState.composerState.mode as MessageComposerMode.Reply + assertThat(replyMode.attachmentThumbnailInfo).isNotNull() + } + } + + @Test + fun `present - handle action reply to a video media message`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + val mediaMessage = aMessageEvent( + content = TimelineItemVideoContent( + body = "video.mp4", + duration = 10L, + videoSource = MediaSource(AN_AVATAR_URL), + thumbnailSource = MediaSource(AN_AVATAR_URL), + mimeType = MimeTypes.Mp4, + blurHash = null, + width = 20, + height = 20, + aspectRatio = 1.0f, + ) + ) + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage)) + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) + val replyMode = finalState.composerState.mode as MessageComposerMode.Reply + assertThat(replyMode.attachmentThumbnailInfo).isNotNull() + } + } + + @Test + fun `present - handle action reply to a file media message`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + val mediaMessage = aMessageEvent( + content = TimelineItemFileContent( + body = "video.mp4", + fileSource = MediaSource(AN_AVATAR_URL), + thumbnailSource = MediaSource(AN_AVATAR_URL), + formattedFileSize = "10 MB", + mimeType = MimeTypes.Pdf, + ) + ) + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage)) + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) + val replyMode = finalState.composerState.mode as MessageComposerMode.Reply + assertThat(replyMode.attachmentThumbnailInfo).isNotNull() + } + } + @Test fun `present - handle action edit`() = runTest { val presenter = createMessagePresenter() @@ -197,6 +304,7 @@ class MessagesPresenterTest { actionListPresenter = actionListPresenter, networkMonitor = FakeNetworkMonitor(), snackbarDispatcher = SnackbarDispatcher(), + messageSummaryFormatter = FakeMessageSummaryFormatter(), ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt index 169912d534..b1aa411148 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt @@ -245,5 +245,6 @@ private fun aMessageEvent( sentTime = "", isMine = isMine, reactionsState = TimelineItemReactions(persistentListOf()), - sendState = EventSendState.Sent(AN_EVENT_ID) + sendState = EventSendState.Sent(AN_EVENT_ID), + inReplyTo = null, ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt index 7d7316b290..3789c36146 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt @@ -92,7 +92,6 @@ class AttachmentsPreviewPresenterTest { private fun anAttachmentsPreviewPresenter( localMedia: LocalMedia = aLocalMedia( uri = mockMediaUrl, - mimeType = MimeTypes.IMAGE_JPEG ), room: MatrixRoom = FakeMatrixRoom() ): AttachmentsPreviewPresenter { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt index bd6be5b517..496c022983 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt @@ -21,7 +21,9 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemReac import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_USER_ID @@ -29,11 +31,13 @@ import io.element.android.libraries.matrix.test.A_USER_NAME import kotlinx.collections.immutable.persistentListOf internal fun aMessageEvent( + eventId: EventId? = AN_EVENT_ID, isMine: Boolean = true, content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false), + inReplyTo: InReplyTo? = null, ) = TimelineItem.Event( - id = AN_EVENT_ID.value, - eventId = AN_EVENT_ID, + id = eventId?.value.orEmpty(), + eventId = eventId, senderId = A_USER_ID, senderDisplayName = A_USER_NAME, senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME), @@ -41,5 +45,6 @@ internal fun aMessageEvent( sentTime = "", isMine = isMine, reactionsState = TimelineItemReactions(persistentListOf()), - sendState = EventSendState.Sent(AN_EVENT_ID) + sendState = EventSendState.Sent(AN_EVENT_ID), + inReplyTo = inReplyTo, ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/media.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/media.kt index 60a01a76d4..1357c05913 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/media.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/media.kt @@ -17,21 +17,18 @@ package io.element.android.features.messages.fixtures import android.net.Uri -import androidx.media3.common.MimeTypes import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.media.local.LocalMedia -import io.mockk.mockk +import io.element.android.features.messages.impl.media.local.MediaInfo +import io.element.android.features.messages.impl.media.local.anImageInfo +import io.element.android.libraries.core.mimetype.MimeTypes fun aLocalMedia( uri: Uri, - mimeType: String = MimeTypes.IMAGE_JPEG, - name: String = "a media", - size: Long = 1000, + mediaInfo: MediaInfo = anImageInfo(), ) = LocalMedia( uri = uri, - mimeType = mimeType, - name = name, - size = size, + info = mediaInfo ) fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt new file mode 100644 index 0000000000..25a62e439a --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt @@ -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 = withContext(coroutineDispatchers.io) { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(Unit) + } + } + + override suspend fun share(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(Unit) + } + } + + override suspend fun open(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(Unit) + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaFactory.kt index 89f4e96173..976aa049f3 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaFactory.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaFactory.kt @@ -20,18 +20,26 @@ import android.net.Uri import io.element.android.features.messages.fixtures.aLocalMedia import io.element.android.features.messages.impl.media.local.LocalMedia import io.element.android.features.messages.impl.media.local.LocalMediaFactory +import io.element.android.features.messages.impl.media.local.MediaInfo import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.media.MediaFile class FakeLocalMediaFactory(private val localMediaUri: Uri) : LocalMediaFactory { var fallbackMimeType: String = MimeTypes.OctetStream + var fallbackName: String = "File name" + var fallbackFileSize = "0B" - override fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia { - return aLocalMedia(uri = localMediaUri, mimeType = mimeType ?: fallbackMimeType) + override fun createFromMediaFile(mediaFile: MediaFile, mediaInfo: MediaInfo): LocalMedia { + return aLocalMedia(uri = localMediaUri, mediaInfo = mediaInfo) } - override fun createFromUri(uri: Uri, mimeType: String?): LocalMedia { - return aLocalMedia(uri, mimeType ?: fallbackMimeType) + override fun createFromUri(uri: Uri, mimeType: String?, name: String?, formattedFileSize: String?): LocalMedia { + val mediaInfo = MediaInfo( + name = name ?: fallbackName, + mimeType = mimeType ?: fallbackMimeType, + formattedFileSize = formattedFileSize ?: fallbackFileSize + ) + return aLocalMedia(uri, mediaInfo) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt index 1ef8097d4a..86c3f3a7a4 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt @@ -19,65 +19,122 @@ package io.element.android.features.messages.media.viewer import android.net.Uri -import androidx.media3.common.MimeTypes import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.media.local.MediaInfo import io.element.android.features.messages.impl.media.viewer.MediaViewerEvents import io.element.android.features.messages.impl.media.viewer.MediaViewerNode import io.element.android.features.messages.impl.media.viewer.MediaViewerPresenter +import io.element.android.features.messages.media.FakeLocalMediaActions import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.libraries.architecture.Async -import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.matrix.test.media.FakeMediaLoader import io.element.android.libraries.matrix.test.media.aMediaSource +import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test -private const val TESTED_MIME_TYPE = MimeTypes.IMAGE_JPEG -private const val TESTED_MEDIA_NAME = "MediaName" +private val TESTED_MEDIA_INFO = MediaInfo( + name = "", + mimeType = "", + formattedFileSize = "" +) class MediaViewerPresenterTest { - private val mockMediaUrl: Uri = mockk("localMediaUri") - private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl) - private val mediaLoader = FakeMediaLoader() + private val mockMediaUri: Uri = mockk("localMediaUri") + private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri) @Test fun `present - download media success scenario`() = runTest { - val presenter = aMediaViewerPresenter() + val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false) + val mediaLoader = FakeMediaLoader(coroutineDispatchers) + val mediaActions = FakeLocalMediaActions(coroutineDispatchers) + val presenter = aMediaViewerPresenter(mediaLoader, mediaActions) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { - val initialState = awaitItem() - assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized) - assertThat(initialState.name).isEqualTo(TESTED_MEDIA_NAME) - val loadingState = awaitItem() - assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java) - testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS + 1) - val successState = awaitItem() - val successData = successState.downloadedMedia.dataOrNull() - assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java) + var state = awaitItem() + assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized) + assertThat(state.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) + state = awaitItem() + assertThat(state.downloadedMedia).isInstanceOf(Async.Loading::class.java) + state = awaitItem() + val successData = state.downloadedMedia.dataOrNull() + assertThat(state.downloadedMedia).isInstanceOf(Async.Success::class.java) assertThat(successData).isNotNull() } } + @Test + fun `present - check all actions `() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false) + val mediaLoader = FakeMediaLoader(coroutineDispatchers) + val mediaActions = FakeLocalMediaActions(coroutineDispatchers) + val presenter = aMediaViewerPresenter(mediaLoader, mediaActions) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + var state = awaitItem() + assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized) + state = awaitItem() + assertThat(state.downloadedMedia).isInstanceOf(Async.Loading::class.java) + // no state changes while media is loading + state.eventSink(MediaViewerEvents.OpenWith) + state.eventSink(MediaViewerEvents.Share) + state.eventSink(MediaViewerEvents.SaveOnDisk) + state = awaitItem() + assertThat(state.downloadedMedia).isInstanceOf(Async.Success::class.java) + // Should succeed without change of state + state.eventSink(MediaViewerEvents.OpenWith) + // Should succeed without change of state + state.eventSink(MediaViewerEvents.Share) + state.eventSink(MediaViewerEvents.SaveOnDisk) + state = awaitItem() + assertThat(state.snackbarMessage).isNotNull() + state = awaitItem() + assertThat(state.snackbarMessage).isNull() + + // Check failures + mediaActions.shouldFail = true + state.eventSink(MediaViewerEvents.OpenWith) + state = awaitItem() + assertThat(state.snackbarMessage).isNotNull() + state = awaitItem() + assertThat(state.snackbarMessage).isNull() + state.eventSink(MediaViewerEvents.Share) + state = awaitItem() + assertThat(state.snackbarMessage).isNotNull() + state = awaitItem() + assertThat(state.snackbarMessage).isNull() + state.eventSink(MediaViewerEvents.SaveOnDisk) + state = awaitItem() + assertThat(state.snackbarMessage).isNotNull() + state = awaitItem() + assertThat(state.snackbarMessage).isNull() + } + } + @Test fun `present - download media failure then retry with success scenario`() = runTest { - val presenter = aMediaViewerPresenter() + val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false) + val mediaLoader = FakeMediaLoader(coroutineDispatchers) + val mediaActions = FakeLocalMediaActions(coroutineDispatchers) + val presenter = aMediaViewerPresenter(mediaLoader, mediaActions) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { mediaLoader.shouldFail = true val initialState = awaitItem() assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized) - assertThat(initialState.name).isEqualTo(TESTED_MEDIA_NAME) + assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) val loadingState = awaitItem() assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java) - testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS) val failureState = awaitItem() assertThat(failureState.downloadedMedia).isInstanceOf(Async.Failure::class.java) mediaLoader.shouldFail = false @@ -86,7 +143,6 @@ class MediaViewerPresenterTest { skipItems(1) val retryLoadingState = awaitItem() assertThat(retryLoadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java) - testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS) val successState = awaitItem() val successData = successState.downloadedMedia.dataOrNull() assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java) @@ -94,16 +150,20 @@ class MediaViewerPresenterTest { } } - private fun aMediaViewerPresenter(mimeType: String = TESTED_MIME_TYPE): MediaViewerPresenter { + private fun aMediaViewerPresenter( + mediaLoader: FakeMediaLoader, + localMediaActions: FakeLocalMediaActions, + ): MediaViewerPresenter { return MediaViewerPresenter( inputs = MediaViewerNode.Inputs( - name = TESTED_MEDIA_NAME, + mediaInfo = TESTED_MEDIA_INFO, mediaSource = aMediaSource(), - mimeType = mimeType, thumbnailSource = null ), localMediaFactory = localMediaFactory, - mediaLoader = mediaLoader + mediaLoader = mediaLoader, + localMediaActions = localMediaActions, + snackbarDispatcher = SnackbarDispatcher() ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt index bfef3b4011..8fc9b702bc 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt @@ -483,5 +483,5 @@ class MessageComposerPresenterTest { } fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE) -fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, AN_EVENT_ID, A_MESSAGE) +fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE) fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt index 3edde16841..b1a17beb6c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt @@ -42,7 +42,8 @@ class TimelineItemGrouperTest { senderDisplayName = "", content = TimelineItemStateEventContent(body = "a state event"), reactionsState = TimelineItemReactions(emptyList().toImmutableList()), - sendState = EventSendState.Sent(AN_EVENT_ID) + sendState = EventSendState.Sent(AN_EVENT_ID), + inReplyTo = null, ) private val aNonGroupableItem = aMessageEvent() private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today")) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/utils/messagesummary/FakeMessageSummaryFormatter.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/utils/messagesummary/FakeMessageSummaryFormatter.kt new file mode 100644 index 0000000000..3f8205e555 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/utils/messagesummary/FakeMessageSummaryFormatter.kt @@ -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 + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt index a6726f9105..615541a3de 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt @@ -29,19 +29,19 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.core.net.toUri -import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.execute import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediaupload.api.MediaPreProcessor -import io.element.android.libraries.mediaupload.api.MediaUploadInfo import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject class RoomDetailsEditPresenter @Inject constructor( @@ -139,13 +139,19 @@ class RoomDetailsEditPresenter @Inject constructor( val results = mutableListOf>() suspend { if (topic.orEmpty().trim() != room.topic.orEmpty().trim()) { - results.add(room.setTopic(topic.orEmpty())) + results.add(room.setTopic(topic.orEmpty()).onFailure { + Timber.e(it, "Failed to set room topic") + }) } if (name.isNotEmpty() && name.trim() != room.name.orEmpty().trim()) { - results.add(room.setName(name)) + results.add(room.setName(name).onFailure { + Timber.e(it, "Failed to set room name") + }) } if (avatarUri?.toString()?.trim() != room.avatarUrl?.trim()) { - results.add(updateAvatar(avatarUri)) + results.add(updateAvatar(avatarUri).onFailure { + Timber.e(it, "Failed to update avatar") + }) } if (results.all { it.isSuccess }) Unit else results.first { it.isFailure }.getOrThrow() }.execute(action) @@ -153,14 +159,12 @@ class RoomDetailsEditPresenter @Inject constructor( private suspend fun updateAvatar(avatarUri: Uri?): Result { return runCatching { - val result = if (avatarUri != null) { - val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow() as? MediaUploadInfo.Image - val byteArray = preprocessed?.file?.readBytes() - byteArray?.let { room.updateAvatar(MimeTypes.Jpeg, it) } ?: error("Could not process the given uri ($avatarUri)") + if (avatarUri != null) { + val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow() + room.updateAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow() } else { - room.removeAvatar() + room.removeAvatar().getOrThrow() } - result.getOrThrow() } } } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt index adbec76ae7..c1d098dab5 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt @@ -21,7 +21,6 @@ import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.features.roomdetails.aMatrixRoom import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditEvents import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditPresenter @@ -29,9 +28,9 @@ import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediaupload.api.MediaUploadInfo -import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.mockk.every import io.mockk.mockk @@ -600,14 +599,9 @@ class RoomDetailsEditPresenterTest { } fakePickerProvider.givenResult(anotherAvatarUri) - fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image( + fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.AnyFile( file = processedFile, info = mockk(), - thumbnailInfo = ThumbnailProcessingInfo( - file = processedFile, - info = mockk(), - blurhash = "", - ) ))) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt index d8c22b9f66..b2f5e0f933 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt @@ -41,17 +41,13 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Snackbar -import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -85,8 +81,8 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.noFontPadding import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator import io.element.android.libraries.designsystem.utils.LogCompositions +import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState import io.element.android.libraries.matrix.api.core.RoomId -import kotlinx.coroutines.launch import io.element.android.libraries.designsystem.R as DrawableR import io.element.android.libraries.ui.strings.R as StringR @@ -184,21 +180,7 @@ fun RoomListContent( } } - val snackbarHostState = remember { SnackbarHostState() } - val snackbarMessageText = if (state.snackbarMessage != null) { - stringResource(state.snackbarMessage.messageResId) - } else null - val coroutineScope = rememberCoroutineScope() - if (snackbarMessageText != null) { - SideEffect { - coroutineScope.launch { - snackbarHostState.showSnackbar( - message = snackbarMessageText, - duration = SnackbarDuration.Short, - ) - } - } - } + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) Scaffold( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), diff --git a/gradle.properties b/gradle.properties index ae25b1ed02..e15ee7a033 100644 --- a/gradle.properties +++ b/gradle.properties @@ -38,6 +38,9 @@ android.nonTransitiveRClass=true org.gradle.caching=true org.gradle.configureondemand=true org.gradle.parallel=true +# Check here for the reasons https://github.com/square/anvil/issues/693 +# useClasspathSnapshot=false is not enough in most cases. +kotlin.incremental=false # Dummy values for signing secrets / nightly signing.element.nightly.storePassword=Secret diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 68dfbedcf2..0690511adf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ media3 = "1.0.2" browser = "1.5.0" # Compose -compose_bom = "2023.05.01" +compose_bom = "2023.06.00" composecompiler = "1.4.7" # Coroutines @@ -153,7 +153,7 @@ telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", v # Analytics posthog = "com.posthog.android:posthog:2.0.3" -sentry_android = "io.sentry:sentry-android:6.21.0" +sentry_android = "io.sentry:sentry-android:6.22.0" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:main-SNAPSHOT" # Di diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/Context.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/Context.kt index 6bf784b100..b730eec3d5 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/Context.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/Context.kt @@ -20,17 +20,24 @@ import android.content.ContentResolver import android.content.Context import android.net.Uri import android.provider.OpenableColumns -import java.io.File +import androidx.core.net.toFile + +fun Context.getMimeType(uri: Uri): String? = when (uri.scheme) { + ContentResolver.SCHEME_CONTENT -> contentResolver.getType(uri) + else -> null +} fun Context.getFileName(uri: Uri): String? = when (uri.scheme) { ContentResolver.SCHEME_CONTENT -> getContentFileName(uri) - else -> uri.path?.let(::File)?.name + ContentResolver.SCHEME_FILE -> uri.toFile().name + else -> null } fun Context.getFileSize(uri: Uri): Long { return when (uri.scheme) { ContentResolver.SCHEME_CONTENT -> getContentFileSize(uri) - else -> uri.path?.let(::File)?.length() + ContentResolver.SCHEME_FILE -> uri.toFile().length() + else -> 0 } ?: 0 } diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts index 09361a5667..f0d937ead3 100644 --- a/libraries/designsystem/build.gradle.kts +++ b/libraries/designsystem/build.gradle.kts @@ -31,6 +31,7 @@ android { // Should not be there, but this is a POC implementation(libs.coil.compose) implementation(libs.accompanist.systemui) + implementation(libs.vanniktech.blurhash) implementation(projects.libraries.elementresources) implementation(projects.libraries.uiStrings) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/blurhash/BlurHashAsyncImage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BlurHashAsyncImage.kt similarity index 97% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/blurhash/BlurHashAsyncImage.kt rename to libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BlurHashAsyncImage.kt index 9237e87e9d..0ddd0b7346 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/blurhash/BlurHashAsyncImage.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BlurHashAsyncImage.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.messages.impl.timeline.components.blurhash +package io.element.android.libraries.designsystem.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/EqualWidthColumn.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/EqualWidthColumn.kt new file mode 100644 index 0000000000..8804066da8 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/EqualWidthColumn.kt @@ -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() + } + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Text.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Text.kt index ef1f035fad..3a59788300 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Text.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Text.kt @@ -66,6 +66,7 @@ fun Text( lineHeight: TextUnit = TextUnit.Unspecified, overflow: TextOverflow = TextOverflow.Clip, softWrap: Boolean = true, + minLines: Int = 1, maxLines: Int = Int.MAX_VALUE, onTextLayout: (TextLayoutResult) -> Unit = {}, style: TextStyle = LocalTextStyle.current @@ -84,6 +85,7 @@ fun Text( lineHeight = lineHeight, overflow = overflow, softWrap = softWrap, + minLines = minLines, maxLines = maxLines, onTextLayout = onTextLayout, style = style, @@ -105,6 +107,7 @@ fun Text( lineHeight: TextUnit = TextUnit.Unspecified, overflow: TextOverflow = TextOverflow.Clip, softWrap: Boolean = true, + minLines: Int = 1, maxLines: Int = Int.MAX_VALUE, inlineContent: ImmutableMap = persistentMapOf(), onTextLayout: (TextLayoutResult) -> Unit = {}, @@ -124,6 +127,7 @@ fun Text( lineHeight = lineHeight, overflow = overflow, softWrap = softWrap, + minLines = minLines, maxLines = maxLines, inlineContent = inlineContent, onTextLayout = onTextLayout, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcher.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt similarity index 71% rename from libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcher.kt rename to libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt index 1131777398..35b81ff324 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcher.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt @@ -18,11 +18,14 @@ package io.element.android.libraries.designsystem.utils import androidx.annotation.StringRes import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import kotlinx.coroutines.Dispatchers +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.res.stringResource import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -56,7 +59,7 @@ fun handleSnackbarMessage( val snackbarMessage by snackbarDispatcher.snackbarMessage.collectAsState(initial = null) LaunchedEffect(snackbarMessage) { if (snackbarMessage != null) { - launch(Dispatchers.Main) { + launch { snackbarDispatcher.clear() } } @@ -64,6 +67,25 @@ fun handleSnackbarMessage( return snackbarMessage } +@Composable +fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostState { + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + val snackbarMessageText = snackbarMessage?.let { + stringResource(id = snackbarMessage.messageResId) + } + LaunchedEffect(snackbarMessage) { + if (snackbarMessageText == null) return@LaunchedEffect + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = snackbarMessageText, + duration = snackbarMessage.duration, + ) + } + } + return snackbarHostState +} + data class SnackbarMessage( @StringRes val messageResId: Int, val duration: SnackbarDuration = SnackbarDuration.Short, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MatrixMediaLoader.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MatrixMediaLoader.kt index 4d1d2445ce..8dd5c625d1 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MatrixMediaLoader.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MatrixMediaLoader.kt @@ -33,8 +33,9 @@ interface MatrixMediaLoader { /** * @param source to fetch the data for. - * @param mimeType: optional mime type + * @param mimeType: optional mime type. + * @param body: optional body which will be used to name the file. * @return a [Result] of [MediaFile] */ - suspend fun downloadMediaFile(source: MediaSource, mimeType: String?): Result + suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaFile.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaFile.kt index 3ef659133d..d4989dbffc 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaFile.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaFile.kt @@ -17,6 +17,7 @@ package io.element.android.libraries.matrix.api.media import java.io.Closeable +import java.io.File /** * A wrapper around a media file on the disk. @@ -25,3 +26,7 @@ import java.io.Closeable interface MediaFile : Closeable { fun path(): String } + +fun MediaFile.toFile(): File { + return File(path()) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index dafaa95936..57e3993926 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -28,11 +28,25 @@ sealed interface EventContent data class MessageContent( val body: String, - val inReplyTo: EventId?, + val inReplyTo: InReplyTo?, val isEdited: Boolean, val type: MessageType? ) : EventContent + +sealed interface InReplyTo { + data class NotLoaded(val eventId: EventId) : InReplyTo + data class Ready( + val eventId: EventId, + val content: MessageContent, + val senderId: UserId, + val senderDisplayName: String?, + val senderAvatarUrl: String?, + ) : InReplyTo + + object Error : InReplyTo +} + object RedactedContent : EventContent data class StickerContent( diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt index 8b667107d7..81aa2dc5c4 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt @@ -32,4 +32,12 @@ data class EventTimelineItem( val senderProfile: ProfileTimelineDetails, val timestamp: Long, val content: EventContent -) +) { + fun inReplyTo(): InReplyTo? { + return (content as? MessageContent)?.inReplyTo + } + fun hasNotLoadedInReplyTo(): Boolean { + val details = inReplyTo() + return details is InReplyTo.NotLoaded || details is InReplyTo.Error + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index f29482edfc..51fc903d8d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -79,6 +79,7 @@ class RustMatrixClient constructor( private val coroutineScope: CoroutineScope, private val dispatchers: CoroutineDispatchers, private val baseDirectory: File, + private val baseCacheDirectory: File, private val clock: SystemClock, ) : MatrixClient { @@ -188,7 +189,7 @@ class RustMatrixClient constructor( override val invitesDataSource: RoomSummaryDataSource get() = rustInvitesDataSource - private val rustMediaLoader = RustMediaLoader(dispatchers, client) + private val rustMediaLoader = RustMediaLoader(baseCacheDirectory, dispatchers, client) override val mediaLoader: MatrixMediaLoader get() = rustMediaLoader diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index df3c7f3d6f..d599029923 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -16,10 +16,12 @@ package io.element.android.libraries.matrix.impl.auth +import android.content.Context import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SingleIn import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService @@ -48,6 +50,7 @@ import org.matrix.rustcomponents.sdk.AuthenticationService as RustAuthentication @ContributesBinding(AppScope::class) @SingleIn(AppScope::class) class RustMatrixAuthenticationService @Inject constructor( + @ApplicationContext private val context: Context, private val baseDirectory: File, private val coroutineScope: CoroutineScope, private val coroutineDispatchers: CoroutineDispatchers, @@ -179,6 +182,7 @@ class RustMatrixAuthenticationService @Inject constructor( coroutineScope = coroutineScope, dispatchers = coroutineDispatchers, baseDirectory = baseDirectory, + baseCacheDirectory = context.cacheDir, clock = clock, ) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt index 9e4f2c53de..df620852cc 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt @@ -24,13 +24,21 @@ import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.mediaSourceFromUrl import org.matrix.rustcomponents.sdk.use +import java.io.File import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource class RustMediaLoader( + baseCacheDirectory: File, private val dispatchers: CoroutineDispatchers, - private val innerClient: Client + private val innerClient: Client, ) : MatrixMediaLoader { + private val cacheDirectory = File(baseCacheDirectory, "temp/media").apply { + if (!exists()) { + mkdirs() + } + } + @OptIn(ExperimentalUnsignedTypes::class) override suspend fun loadMediaContent(source: MediaSource): Result = withContext(dispatchers.io) { @@ -59,14 +67,16 @@ class RustMediaLoader( } } - override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?): Result = + override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result = withContext(dispatchers.io) { runCatching { source.toRustMediaSource().use { mediaSource -> val mediaFile = innerClient.getMediaFile( mediaSource = mediaSource, - body = null, - mimeType = mimeType ?: "application/octet-stream" + body = body, + mimeType = mimeType ?: "application/octet-stream", + //TODO uncomment when rust api will be merged + //tempDir = cacheDirectory.path, ) RustMediaFile(mediaFile) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt index 5b4eaa9e36..c90e672f28 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt @@ -16,12 +16,19 @@ package io.element.android.libraries.matrix.impl.timeline +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.TimelineItem +import timber.log.Timber class MatrixTimelineItemMapper( + private val room: Room, + private val coroutineScope: CoroutineScope, private val virtualTimelineItemMapper: VirtualTimelineItemMapper = VirtualTimelineItemMapper(), private val eventTimelineItemMapper: EventTimelineItemMapper= EventTimelineItemMapper(), ) { @@ -30,6 +37,12 @@ class MatrixTimelineItemMapper( val asEvent = it.asEvent() if (asEvent != null) { val eventTimelineItem = eventTimelineItemMapper.map(asEvent) + + + if (eventTimelineItem.hasNotLoadedInReplyTo() && eventTimelineItem.eventId != null) { + fetchDetailsForEvent(eventTimelineItem.eventId!!) + } + return MatrixTimelineItem.Event(eventTimelineItem) } val asVirtual = it.asVirtual() @@ -39,4 +52,13 @@ class MatrixTimelineItemMapper( } return MatrixTimelineItem.Other } + + private fun fetchDetailsForEvent(eventId: EventId) = coroutineScope.launch { + runCatching { + room.fetchDetailsForEvent(eventId.value) + }.onFailure { + Timber.e(it) + } + } + } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt index 923815a714..408861226b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt @@ -63,6 +63,8 @@ class RustMatrixTimeline( ) private val timelineItemFactory = MatrixTimelineItemMapper( + room = innerRoom, + coroutineScope = coroutineScope, virtualTimelineItemMapper = VirtualTimelineItemMapper(), eventTimelineItemMapper = EventTimelineItemMapper( contentMapper = TimelineEventContentMapper( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt index 2e4693c1fb..8a052d1a3a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt @@ -17,11 +17,13 @@ package io.element.android.libraries.matrix.impl.timeline.item.event import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType @@ -31,6 +33,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT import io.element.android.libraries.matrix.impl.media.map import org.matrix.rustcomponents.sdk.Message import org.matrix.rustcomponents.sdk.MessageType +import org.matrix.rustcomponents.sdk.ProfileDetails +import org.matrix.rustcomponents.sdk.RepliedToEventDetails import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat @@ -66,9 +70,26 @@ class EventMessageMapper { } } } + val inReplyToId = it.inReplyTo()?.eventId?.let(::EventId) + val inReplyToEvent: InReplyTo? = (it.inReplyTo()?.event)?.use { details -> + when (details) { + is RepliedToEventDetails.Ready -> { + val senderProfile = details.senderProfile as? ProfileDetails.Ready + InReplyTo.Ready( + eventId = inReplyToId!!, + content = map(details.message), + senderId = UserId(details.sender), + senderDisplayName = senderProfile?.displayName, + senderAvatarUrl = senderProfile?.avatarUrl, + ) + } + is RepliedToEventDetails.Error -> InReplyTo.Error + is RepliedToEventDetails.Pending, is RepliedToEventDetails.Unavailable -> InReplyTo.NotLoaded(inReplyToId!!) + } + } MessageContent( body = it.body(), - inReplyTo = it.inReplyTo()?.eventId?.let(::EventId), + inReplyTo = inReplyToEvent, isEdited = it.isEdited(), type = type ) diff --git a/libraries/matrix/test/build.gradle.kts b/libraries/matrix/test/build.gradle.kts index 6d9ca1eb8e..4e8893aab6 100644 --- a/libraries/matrix/test/build.gradle.kts +++ b/libraries/matrix/test/build.gradle.kts @@ -26,4 +26,6 @@ dependencies { api(projects.libraries.core) api(projects.libraries.matrix.api) api(libs.coroutines.core) + implementation(libs.coroutines.test) + implementation(projects.tests.testutils) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 85c3555844..79a8186e1a 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.matrix.test +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId @@ -36,15 +37,18 @@ import io.element.android.libraries.matrix.test.pushers.FakePushersService import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.test.StandardTestDispatcher class FakeMatrixClient( override val sessionId: SessionId = A_SESSION_ID, + private val coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), private val userDisplayName: Result = Result.success(A_USER_NAME), private val userAvatarURLString: Result = Result.success(AN_AVATAR_URL), override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(), override val invitesDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(), - override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(), + override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(coroutineDispatchers), private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), private val pushersService: FakePushersService = FakePushersService(), private val notificationService: FakeNotificationService = FakeNotificationService(), diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt index 96c49aa165..4282860c99 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt @@ -16,40 +16,40 @@ package io.element.android.libraries.matrix.test.media +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlin.coroutines.coroutineContext -class FakeMediaLoader : MatrixMediaLoader { +class FakeMediaLoader(private val coroutineDispatchers: CoroutineDispatchers) : MatrixMediaLoader { var shouldFail = false - override suspend fun loadMediaContent(source: MediaSource): Result { - delay(FAKE_DELAY_IN_MS) - return if (shouldFail) { + override suspend fun loadMediaContent(source: MediaSource): Result = withContext(coroutineDispatchers.io){ + if (shouldFail) { Result.failure(RuntimeException()) } else { - return Result.success(ByteArray(0)) + Result.success(ByteArray(0)) } } - override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result { - delay(FAKE_DELAY_IN_MS) - return if (shouldFail) { + override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result = withContext(coroutineDispatchers.io){ + if (shouldFail) { Result.failure(RuntimeException()) } else { - return Result.success(ByteArray(0)) + Result.success(ByteArray(0)) } } - override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?): Result { - delay(FAKE_DELAY_IN_MS) - return if (shouldFail) { + override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result = withContext(coroutineDispatchers.io){ + if (shouldFail) { Result.failure(RuntimeException()) } else { - return Result.success(FakeMediaFile("")) + Result.success(FakeMediaFile("")) } } } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt new file mode 100644 index 0000000000..5a3bad8988 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt @@ -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 diff --git a/libraries/mediaupload/test/build.gradle.kts b/libraries/mediaupload/test/build.gradle.kts index 6daa89b152..535ba51ac4 100644 --- a/libraries/mediaupload/test/build.gradle.kts +++ b/libraries/mediaupload/test/build.gradle.kts @@ -19,7 +19,7 @@ plugins { } android { - namespace = "io.element.android.libraries.matrix.test" + namespace = "io.element.android.libraries.mediaupload.test" } dependencies { diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt index 3873101c97..dc8b664764 100644 --- a/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt @@ -32,6 +32,6 @@ class RetrofitFactory @Inject constructor( fun create(baseUrl: String): Retrofit = Retrofit.Builder() .baseUrl(baseUrl.ensureTrailingSlash()) .addConverterFactory(json.get().asConverterFactory("application/json".toMediaType())) - .callFactory(okHttpClient.get()) + .callFactory { request -> okHttpClient.get().newCall(request) } .build() } diff --git a/libraries/push/impl/src/main/res/values/localazy.xml b/libraries/push/impl/src/main/res/values/localazy.xml index d38bf7d8dd..922d35b3e1 100644 --- a/libraries/push/impl/src/main/res/values/localazy.xml +++ b/libraries/push/impl/src/main/res/values/localazy.xml @@ -7,6 +7,7 @@ "** Failed to send - please open room" "Join" "Reject" + "invited you" "New Messages" "Mark as read" "Me" diff --git a/libraries/textcomposer/build.gradle.kts b/libraries/textcomposer/build.gradle.kts index 6af1d8c598..dee2abc5c6 100644 --- a/libraries/textcomposer/build.gradle.kts +++ b/libraries/textcomposer/build.gradle.kts @@ -22,9 +22,6 @@ plugins { android { namespace = "io.element.android.libraries.textcomposer" - buildFeatures { - viewBinding = true - } } dependencies { @@ -33,9 +30,7 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) - implementation(libs.wysiwyg) - implementation(libs.androidx.constraintlayout) - implementation(libs.androidx.material) ksp(libs.showkase.processor) } diff --git a/libraries/textcomposer/src/main/AndroidManifest.xml b/libraries/textcomposer/src/main/AndroidManifest.xml deleted file mode 100644 index 19db0c3d57..0000000000 --- a/libraries/textcomposer/src/main/AndroidManifest.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt index 093fb865fa..5539b781ea 100644 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt +++ b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.textcomposer import android.os.Parcelable import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import kotlinx.parcelize.Parcelize sealed interface MessageComposerMode : Parcelable { @@ -38,6 +39,7 @@ sealed interface MessageComposerMode : Parcelable { @Parcelize class Reply( val senderName: String, + val attachmentThumbnailInfo: AttachmentThumbnailInfo?, override val eventId: EventId, override val defaultContent: CharSequence ) : Special(eventId, defaultContent) diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 92bcff5000..ccf4030ce1 100644 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -59,8 +59,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.ElementTextStyles @@ -73,6 +75,10 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.ui.strings.R as StringR @OptIn(ExperimentalMaterial3Api::class) @@ -180,21 +186,99 @@ private fun ComposerModeView( ) { when (composerMode) { is MessageComposerMode.Edit -> { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp)) { - Icon( - resourceId = VectorIcons.Edit, - contentDescription = stringResource(R.string.editing), - tint = MaterialTheme.colorScheme.secondary, - modifier = Modifier.size(16.dp), - ) + EditingModeView(onResetComposerMode = onResetComposerMode, modifier = modifier) + } + is MessageComposerMode.Reply -> { + ReplyToModeView( + modifier = modifier.padding(8.dp), + senderName = composerMode.senderName, + text = composerMode.defaultContent.toString(), + attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo, + onResetComposerMode = onResetComposerMode, + ) + } + else -> Unit + } +} + +@Composable +private fun EditingModeView( + onResetComposerMode: () -> Unit, + modifier: Modifier = Modifier, +) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp)) { + Icon( + resourceId = VectorIcons.Edit, + contentDescription = stringResource(R.string.editing), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(16.dp), + ) + Text( + stringResource(R.string.editing), + style = ElementTextStyles.Regular.caption2, + textAlign = TextAlign.Start, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(StringR.string.action_close), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .size(16.dp) + .clickable( + enabled = true, + onClick = onResetComposerMode, + interactionSource = MutableInteractionSource(), + indication = rememberRipple(bounded = false) + ), + + ) + } +} + +@Composable +private fun ReplyToModeView( + senderName: String, + text: String?, + attachmentThumbnailInfo: AttachmentThumbnailInfo?, + onResetComposerMode: () -> Unit, + modifier: Modifier = Modifier, +) { + val paddings = if (attachmentThumbnailInfo != null) { + PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp) + } else { + PaddingValues(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp) + } + Row( + modifier + .clip(RoundedCornerShape(13.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(paddings) + ) { + if (attachmentThumbnailInfo != null) { + AttachmentThumbnail( + info = attachmentThumbnailInfo, + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(9.dp)) + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Column(verticalArrangement = Arrangement.SpaceEvenly) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth() + ) { Text( - stringResource(R.string.editing), - style = ElementTextStyles.Regular.caption2, + senderName, + style = ElementTextStyles.Regular.caption2.copy(fontWeight = FontWeight.Medium), textAlign = TextAlign.Start, - color = MaterialTheme.colorScheme.secondary, + color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f) ) Icon( @@ -209,11 +293,19 @@ private fun ComposerModeView( interactionSource = MutableInteractionSource(), indication = rememberRipple(bounded = false) ), - ) } + + Text( + modifier = Modifier.fillMaxWidth(), + text = text.orEmpty(), + style = ElementTextStyles.Regular.caption1, + textAlign = TextAlign.Start, + color = LocalColors.current.placeholder, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } - else -> Unit } } @@ -289,14 +381,30 @@ private fun BoxScope.SendButton( @Preview @Composable -internal fun TextComposerLightPreview() = ElementPreviewLight { ContentToPreview() } +internal fun TextComposerSimpleLightPreview() = ElementPreviewLight { SimpleContentToPreview() } @Preview @Composable -internal fun TextComposerDarkPreview() = ElementPreviewDark { ContentToPreview() } +internal fun TextComposerSimpleDarkPreview() = ElementPreviewDark { SimpleContentToPreview() } + +@Preview +@Composable +internal fun TextComposerEditLightPreview() = ElementPreviewLight { EditContentToPreview() } + +@Preview +@Composable +internal fun TextComposerEditDarkPreview() = ElementPreviewDark { EditContentToPreview() } + +@Preview +@Composable +internal fun TextComposerReplyLightPreview() = ElementPreviewLight { ReplyContentToPreview() } + +@Preview +@Composable +internal fun TextComposerReplyDarkPreview() = ElementPreviewDark { ReplyContentToPreview() } @Composable -private fun ContentToPreview() { +private fun SimpleContentToPreview() { Column { TextComposer( onSendMessage = {}, @@ -322,10 +430,89 @@ private fun ContentToPreview() { composerCanSendMessage = true, composerText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", ) + } +} + +@Composable +private fun EditContentToPreview() { + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text"), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) +} + +@Composable +private fun ReplyContentToPreview() { + Column { TextComposer( onSendMessage = {}, onComposerTextChange = {}, - composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text"), + composerMode = MessageComposerMode.Reply( + senderName = "Alice", + eventId = EventId("$1234"), + attachmentThumbnailInfo = null, + defaultContent = "A message\n" + + "With several lines\n" + + "To preview larger textfields and long lines with overflow" + ), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Reply( + senderName = "Alice", + eventId = EventId("$1234"), + attachmentThumbnailInfo = AttachmentThumbnailInfo( + mediaSource = MediaSource("https://domain.com/image.jpg"), + textContent = "image.jpg", + type = AttachmentThumbnailType.Image, + blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr", + ), + defaultContent = "image.jpg" + ), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Reply( + senderName = "Alice", + eventId = EventId("$1234"), + attachmentThumbnailInfo = AttachmentThumbnailInfo( + mediaSource = MediaSource("https://domain.com/video.mp4"), + textContent = "video.mp4", + type = AttachmentThumbnailType.Video, + blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr", + ), + defaultContent = "video.mp4" + ), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Reply( + senderName = "Alice", + eventId = EventId("$1234"), + attachmentThumbnailInfo = AttachmentThumbnailInfo( + mediaSource = null, + textContent = "logs.txt", + type = AttachmentThumbnailType.File, + blurHash = null, + ), + defaultContent = "logs.txt" + ), onResetComposerMode = {}, composerCanSendMessage = true, composerText = "A message", diff --git a/libraries/textcomposer/src/main/res/drawable/bg_rich_text_menu_button.xml b/libraries/textcomposer/src/main/res/drawable/bg_rich_text_menu_button.xml deleted file mode 100644 index 647dc58213..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/bg_rich_text_menu_button.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/bottomsheet_handle.xml b/libraries/textcomposer/src/main/res/drawable/bottomsheet_handle.xml deleted file mode 100644 index 3d66d9db2e..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/bottomsheet_handle.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_bold.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_bold.xml deleted file mode 100644 index 4a3051618d..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_bold.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_collapse.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_collapse.xml deleted file mode 100644 index 625399e53a..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_collapse.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_full_screen.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_full_screen.xml deleted file mode 100644 index d8d5f8ef4d..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_full_screen.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_italic.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_italic.xml deleted file mode 100644 index 11a63eb7bb..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_italic.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_close.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_close.xml deleted file mode 100644 index b67fad6749..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_close.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml deleted file mode 100644 index 009cfc303f..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_save.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_save.xml deleted file mode 100644 index 7591806ba6..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_save.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_strikethrough.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_strikethrough.xml deleted file mode 100644 index 937c3a08a4..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_strikethrough.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_underlined.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_underlined.xml deleted file mode 100644 index ac8ff7a96d..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_underlined.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_quote.xml b/libraries/textcomposer/src/main/res/drawable/ic_quote.xml deleted file mode 100644 index 706bf88faa..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_quote.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_rich_composer_add.xml b/libraries/textcomposer/src/main/res/drawable/ic_rich_composer_add.xml deleted file mode 100644 index 6f812c8b44..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_rich_composer_add.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_rich_composer_send.xml b/libraries/textcomposer/src/main/res/drawable/ic_rich_composer_send.xml deleted file mode 100644 index 3373db1399..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_rich_composer_send.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - diff --git a/libraries/textcomposer/src/main/res/layout/composer_rich_text_layout.xml b/libraries/textcomposer/src/main/res/layout/composer_rich_text_layout.xml deleted file mode 100644 index 2e8eb80855..0000000000 --- a/libraries/textcomposer/src/main/res/layout/composer_rich_text_layout.xml +++ /dev/null @@ -1,232 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/libraries/textcomposer/src/main/res/layout/view_rich_text_menu_button.xml b/libraries/textcomposer/src/main/res/layout/view_rich_text_menu_button.xml deleted file mode 100644 index fa264045e7..0000000000 --- a/libraries/textcomposer/src/main/res/layout/view_rich_text_menu_button.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index f3cada2464..43d19f2202 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -34,6 +34,7 @@ "No" "Not now" "OK" + "Open with" "Quick reply" "Quote" "Remove" @@ -145,7 +146,20 @@ "This is the beginning of %1$s." "This is the beginning of this conversation." "New" + "Change account provider" + "Continue" + "Homeserver address" + "Enter a search term or a domain address." + "Search for a company, community, or private server." + "Find an account provider" + "You’re about to sign in to %s" + "This is where you conversations will live — just like you would use an email provider to keep your emails." + "You’re about to create an account on %s" "Share analytics data" + "Matrix.org is an open network for secure, decentralized communication." + "Other" + "Use a different account provider, such as your own private server or a work account." + "Change account provider" "Failed selecting media, please try again." "Failed processing media to upload, please try again." "Failed uploading media, please try again." @@ -168,4 +182,4 @@ "You can read all our terms %1$s." "here" "Block user" - + \ No newline at end of file diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt index 12481b81e1..62602a475b 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt @@ -40,6 +40,7 @@ class MainActivity : ComponentActivity() { val baseDirectory = File(applicationContext.filesDir, "sessions") RustMatrixAuthenticationService( + context = applicationContext, baseDirectory = baseDirectory, coroutineScope = Singleton.appScope, coroutineDispatchers = Singleton.coroutineDispatchers, diff --git a/tests/testutils/build.gradle.kts b/tests/testutils/build.gradle.kts index fda44f46d1..d7c17c7895 100644 --- a/tests/testutils/build.gradle.kts +++ b/tests/testutils/build.gradle.kts @@ -28,12 +28,6 @@ android { dependencies { implementation(libs.test.junit) - implementation(libs.test.mockk) - implementation(libs.test.truth) - implementation(libs.test.turbine) implementation(libs.coroutines.test) - implementation(projects.libraries.matrix.test) - implementation(projects.services.appnavstate.test) - implementation(projects.services.appnavstate.test) implementation(projects.libraries.core) } diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png index 27ad024712..bc5044cd40 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:846f4846bed07b1aa030c07fbde42bdf38f6329250e1641ad83a9bd8786ea683 -size 183396 +oid sha256:ade6aa1e0fd7731173f2ae3424a931234d64e0aa8334a93c983ee46ff6dc5ce5 +size 17170 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png index 7db0c02d08..27ad024712 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e4c14c033b2d4961bad5d05ae7eb61e72fc542e497bb09770271980321832607 -size 98742 +oid sha256:846f4846bed07b1aa030c07fbde42bdf38f6329250e1641ad83a9bd8786ea683 +size 183396 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7db0c02d08 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4c14c033b2d4961bad5d05ae7eb61e72fc542e497bb09770271980321832607 +size 98742 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index d400660085..9657e72a1c 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e -size 393618 +oid sha256:8d26e9c883bfc2c45ad6a48115fe450df35dd1653644a512166716460cedcc7e +size 395351 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png index d400660085..4449898c52 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e -size 393618 +oid sha256:dd52b8ee709319d8b4e5fbc33888248975593cab1e45fb9e81b0e0178e529a66 +size 395357 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png index d400660085..9657e72a1c 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e -size 393618 +oid sha256:8d26e9c883bfc2c45ad6a48115fe450df35dd1653644a512166716460cedcc7e +size 395351 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png index d400660085..920cdf6e71 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e -size 393618 +oid sha256:49d8a19399542ae40d10ca7f07369fa68f1f4fbca52a61ffe0cdfa6d7fe6409a +size 395361 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png index d400660085..fca921c50b 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e -size 393618 +oid sha256:c1c1eedbab868e0c2501220293608572850e052f685f7076ec939b3f1a9abf27 +size 4464 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..92a9cc43ff --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af5875f111c8763508615860bd04f0feee42190da78dff3968ec5598f112fbdc +size 6388 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..289d08f536 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c8a175985e271948423d677b0d79e9ae930aa227c417d5c78e567abda40e378 +size 16210 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b807b8219f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:367e2b224070dcc8ceb218b17336cd406b650b66f9c0fff50f8ec6093bb174dd +size 16182 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index 8d56987170..a179702410 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6efe12c845bdac4f297fae51e23302b046434c898d4886da0890020375621632 -size 10518 +oid sha256:8fd21177b0b12bed1327986f084e1ee53e317751e1a30585d5ac72a1c8f35593 +size 9646 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png index 22e3c24e17..d6b5462836 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0d444880e0593058b55baed32e06231619575ce25ac5fb955da88b59a185d70f -size 13029 +oid sha256:1cc95f45e3761a7d988ce4e3708fa92d2544e3f652f435c86dd261c9ca6f31f0 +size 12130 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png index 8db2cadb53..f6621cd56a 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b6ed694e25efd8293996045a36e9da57a3a9a971fbb2c233dc8755885f1981e5 -size 23613 +oid sha256:5f53fe35923250b86a635b48bf8d4ee5e79df1a7844fdc3db5a847651547e1a4 +size 23189 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index 756a3ce487..ce2ebcd33f 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0a4ca40778073bdae1fa023751e9bb2de2d0e0dbdd4d08a1e978ab07b1f9c37a -size 9937 +oid sha256:ce311b7115be9f4de478fbb84c112a11d59e1dd7da1627774802902a8aa61a28 +size 9176 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_1,NEXUS_5,1.0,en].png index 1cad701025..b14dfa2b4b 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:80eda21a7258ecd15c8770c528a5c786a0d68c610e1bc34a45cb13597932dc75 -size 12070 +oid sha256:463a8a5a05102224eccc552ece41084b1b355682f684c4a063d614a0928d810e +size 11309 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_2,NEXUS_5,1.0,en].png index 63365a9e39..f0be03f4c8 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a2bfd6994de21f68e162a1cdfe75d0c82f6693ef2fbbaa0dc740a6ee6d2a466 -size 21193 +oid sha256:8e80b3348008def046f743af8f440b03612a163040f16aac4d0dac41632ff171 +size 20833 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png index 453308ca1b..74bd95f3b6 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:21426c10fe7c8b13628f2c15bd437ca6a727ed9baa4c85f5ef2a9a0e32e42139 -size 62722 +oid sha256:d9338c43f771fc2a04c825d40358b01bd4ad3dca2512e3a12408fd2d345e8745 +size 61796 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png index ad62e92e3e..ae405dfff1 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:62bc8700035bd87c9c7045c1b963e72c1ba3970c8232d8dbc9ba31df892a91af -size 73999 +oid sha256:68b16f1231e3f328a30458956f395bcbe77afecee2075b07d88dd326beff282a +size 73031 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_4,NEXUS_5,1.0,en].png index 14259d95f3..649d3cde2b 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:354e63f30121add1d2431da33645be1fb5a813fb90dd757c381d866904aa232b -size 62593 +oid sha256:9a46df297336d5224b7e08a52038999ad31d947ecf980b644f3ac988a69de773 +size 61818 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_5,NEXUS_5,1.0,en].png index 12e3c4b419..b4dea5215a 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b51fad2dd74f6e7b59c6df8e262b07523ea920bda514b2eb1442f0e65a1fcebb -size 74415 +oid sha256:2c420df941878750c090726a8f9a6a2e6f7fdb2ac85aa63321f9efe77032213f +size 73234 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerDarkPreview_0_null,NEXUS_5,1.0,en].png deleted file mode 100644 index 72f00d3be0..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerDarkPreview_0_null,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7441f73e8567bbac360867f9b860621ec4766a67d5295d04cda45a09f942d0b5 -size 47865 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e85b7a81af --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1207e4830152c3387f9464fa4f10bd92f605d14b37f1c556d8e610060246ac2e +size 14094 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8e266f9795 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:127ec47cd443c96270f07d6d3ca132a184c6ad99ab8ade04dc3d60f5a6f555da +size 13575 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerLightPreview_0_null,NEXUS_5,1.0,en].png deleted file mode 100644 index 4cb8b441f3..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerLightPreview_0_null,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f44fe7919578afe43e77b414f294e2ae7f1761c9528feeab548303428bfeba43 -size 46117 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c8904142b2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8922908539dc4978a23f72342e46a4c259322559d1f0dbe978dcf3ae8a4e79bf +size 66820 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e4c0408f4e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a5b67e808ef8d171167e7fb241ba76b7e6d5ae48d193f6384bf3e2c4a0743f3 +size 66423 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1c8d0ea0a1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97fb04ff64617a6ba8c683a39921864bded4b05269c958aa4c91fda0bc21963a +size 39298 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6896fab3c2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd9f6eaf4bfc4b226f1fde5bdaf0262b95e3f9ac2e7e0d90648d72ef0add11d2 +size 37492