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