Merge branch 'develop' into jonny/timeline-poll-edited

This commit is contained in:
Benoit Marty 2023-12-04 16:01:09 +01:00 committed by GitHub
commit f6ec76b5ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
602 changed files with 5298 additions and 1508 deletions

View file

@ -39,8 +39,6 @@ import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
import io.element.android.features.messages.impl.forward.ForwardMessagesNode
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.report.ReportMessageNode
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@ -62,6 +60,8 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
import kotlinx.collections.immutable.ImmutableList
import kotlinx.parcelize.Parcelize
@ -180,6 +180,8 @@ class MessagesFlowNode @AssistedInject constructor(
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
canDownload = true,
canShare = true,
)
createNode<MediaViewerNode>(buildContext, listOf(inputs))
}

View file

@ -79,6 +79,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.room.canRedactAsState
@ -108,6 +109,7 @@ class MessagesPresenter @AssistedInject constructor(
private val featureFlagsService: FeatureFlagService,
@Assisted private val navigator: MessagesNavigator,
private val buildMeta: BuildMeta,
private val currentSessionIdHolder: CurrentSessionIdHolder,
) : Presenter<MessagesState> {
private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator)
@ -144,6 +146,16 @@ class MessagesPresenter @AssistedInject constructor(
mutableStateOf(false)
}
var canJoinCall by rememberSaveable {
mutableStateOf(false)
}
LaunchedEffect(currentSessionIdHolder.current) {
withContext(dispatchers.io) {
canJoinCall = room.canUserJoinCall(userId = currentSessionIdHolder.current).getOrDefault(false)
}
}
val inviteProgress = remember { mutableStateOf<Async<Unit>>(Async.Uninitialized) }
var showReinvitePrompt by remember { mutableStateOf(false) }
LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow) {
@ -162,8 +174,6 @@ class MessagesPresenter @AssistedInject constructor(
val enableTextFormatting by preferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
var enableVoiceMessages by remember { mutableStateOf(false) }
// TODO add min power level to use this feature in the future?
val enableInRoomCalls = true
LaunchedEffect(featureFlagsService) {
enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages)
}
@ -193,6 +203,12 @@ class MessagesPresenter @AssistedInject constructor(
}
}
val callState = when {
!canJoinCall -> RoomCallState.DISABLED
roomInfo?.hasRoomCall == true -> RoomCallState.ONGOING
else -> RoomCallState.ENABLED
}
return MessagesState(
roomId = room.roomId,
roomName = roomName,
@ -213,9 +229,8 @@ class MessagesPresenter @AssistedInject constructor(
inviteProgress = inviteProgress.value,
enableTextFormatting = enableTextFormatting,
enableVoiceMessages = enableVoiceMessages,
enableInRoomCalls = enableInRoomCalls,
appName = buildMeta.applicationName,
isCallOngoing = roomInfo?.hasRoomCall ?: false,
callState = callState,
eventSink = { handleEvents(it) }
)
}
@ -224,7 +239,7 @@ class MessagesPresenter @AssistedInject constructor(
return AvatarData(
id = id,
name = name,
url = avatarUrl,
url = avatarUrl ?: room.avatarUrl,
size = AvatarSize.TimelineRoom
)
}

View file

@ -51,8 +51,13 @@ data class MessagesState(
val showReinvitePrompt: Boolean,
val enableTextFormatting: Boolean,
val enableVoiceMessages: Boolean,
val enableInRoomCalls: Boolean,
val isCallOngoing: Boolean,
val callState: RoomCallState,
val appName: String,
val eventSink: (MessagesEvents) -> Unit
)
enum class RoomCallState {
ENABLED,
ONGOING,
DISABLED
}

View file

@ -66,7 +66,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
),
),
aMessagesState().copy(
isCallOngoing = true,
callState = RoomCallState.ONGOING,
),
aMessagesState().copy(
enableVoiceMessages = true,
@ -75,6 +75,9 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
showSendFailureDialog = true
),
),
aMessagesState().copy(
callState = RoomCallState.DISABLED,
),
)
}
@ -117,8 +120,7 @@ fun aMessagesState() = MessagesState(
showReinvitePrompt = false,
enableTextFormatting = true,
enableVoiceMessages = true,
enableInRoomCalls = true,
isCallOngoing = false,
callState = RoomCallState.ENABLED,
appName = "Element",
eventSink = {}
)

View file

@ -191,10 +191,9 @@ fun MessagesView(
MessagesViewTopBar(
roomName = state.roomName.dataOrNull(),
roomAvatar = state.roomAvatar.dataOrNull(),
inRoomCallsEnabled = state.enableInRoomCalls,
callState = state.callState,
onBackPressed = onBackPressed,
onRoomDetailsClicked = onRoomDetailsClicked,
isCallOngoing = state.isCallOngoing,
onJoinCallClicked = onJoinCallClicked,
)
}
@ -449,8 +448,7 @@ private fun MessagesViewComposerBottomSheetContents(
private fun MessagesViewTopBar(
roomName: String?,
roomAvatar: AvatarData?,
inRoomCallsEnabled: Boolean,
isCallOngoing: Boolean,
callState: RoomCallState,
onRoomDetailsClicked: () -> Unit,
onJoinCallClicked: () -> Unit,
onBackPressed: () -> Unit,
@ -477,13 +475,11 @@ private fun MessagesViewTopBar(
}
},
actions = {
if (inRoomCallsEnabled) {
if (isCallOngoing) {
JoinCallMenuItem(onJoinCallClicked = onJoinCallClicked)
} else {
IconButton(onClick = onJoinCallClicked) {
Icon(CompoundIcons.VideoCall, contentDescription = stringResource(CommonStrings.a11y_start_call))
}
if (callState == RoomCallState.ONGOING) {
JoinCallMenuItem(onJoinCallClicked = onJoinCallClicked)
} else {
IconButton(onClick = onJoinCallClicked, enabled = callState != RoomCallState.DISABLED) {
Icon(CompoundIcons.VideoCall, contentDescription = stringResource(CommonStrings.a11y_start_call))
}
}
Spacer(Modifier.width(8.dp))

View file

@ -18,7 +18,7 @@ package io.element.android.features.messages.impl.attachments
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import kotlinx.parcelize.Parcelize
@Immutable

View file

@ -33,6 +33,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.coroutines.coroutineContext
class AttachmentsPreviewPresenter @AssistedInject constructor(
@ -114,6 +115,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
sendActionState.value = SendActionState.Done
},
onFailure = { error ->
Timber.e(error, "Failed to send attachment")
if (error is CancellationException) {
throw error
} else {

View file

@ -19,10 +19,10 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.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.anImageInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.aFileInfo
import io.element.android.libraries.mediaviewer.api.local.anImageInfo
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
override val values: Sequence<AttachmentsPreviewState>

View file

@ -32,7 +32,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.media.local.LocalMediaView
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.ProgressDialogType
@ -40,6 +39,7 @@ import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.mediaviewer.api.local.LocalMediaView
import io.element.android.libraries.ui.strings.CommonStrings
@Composable

View file

@ -1,27 +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.helper
fun formatFileExtensionAndSize(extension: String, size: String?): String {
return buildString {
append(extension.uppercase())
if (size != null) {
append(' ')
append("($size)")
}
}
}

View file

@ -1,205 +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.app.Activity
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.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberCoroutineScope
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.androidutils.system.startInstallFromSourceIntent
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import kotlinx.coroutines.launch
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
private var apkInstallLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>? = null
private var pendingMedia: LocalMedia? = null
@Composable
override fun Configure() {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
apkInstallLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
) { activityResult ->
if (activityResult.resultCode == Activity.RESULT_OK) {
pendingMedia?.let {
coroutineScope.launch {
openFile(it)
}
}
} else {
// User cancelled
}
pendingMedia = null
}
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 {
when (localMedia.info.mimeType) {
MimeTypes.Apk -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (activityContext?.packageManager?.canRequestPackageInstalls() == false) {
pendingMedia = localMedia
activityContext?.startInstallFromSourceIntent(apkInstallLauncher!!).let { }
} else {
openFile(localMedia)
}
} else {
openFile(localMedia)
}
}
else -> openFile(localMedia)
}
}.onSuccess {
Timber.v("Open media succeed")
}.onFailure {
Timber.e(it, "Open media failed")
}
}
private suspend fun openFile(localMedia: LocalMedia) {
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)
}
}
private fun LocalMedia.toShareableUri(): Uri {
val mediaAsFile = this.toFile()
val authority = "${buildMeta.applicationId}.fileprovider"
return FileProvider.getUriForFile(context, authority, mediaAsFile).normalizeScheme()
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveOnDiskUsingMediaStore(localMedia: LocalMedia) {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, localMedia.info.name)
put(MediaStore.MediaColumns.MIME_TYPE, localMedia.info.mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val resolver = context.contentResolver
val outputUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (outputUri != null) {
localMedia.openStream()?.use { input ->
resolver.openOutputStream(outputUri).use { output ->
input.copyTo(output!!, DEFAULT_BUFFER_SIZE)
}
}
}
}
private fun saveOnDiskUsingExternalStorageApi(localMedia: LocalMedia) {
val target = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
localMedia.info.name
)
localMedia.openStream()?.use { input ->
FileOutputStream(target).use { output ->
input.copyTo(output)
}
}
}
private fun LocalMedia.openStream(): InputStream? {
return context.contentResolver.openInputStream(uri)
}
/**
* Tries to extract a file from the uri.
*/
private fun LocalMedia.toFile(): File {
return uri.toFile()
}
}

View file

@ -1,73 +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.Context
import android.net.Uri
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor
import io.element.android.libraries.androidutils.file.getFileName
import io.element.android.libraries.androidutils.file.getFileSize
import io.element.android.libraries.androidutils.file.getMimeType
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.media.toFile
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class AndroidLocalMediaFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val fileSizeFormatter: FileSizeFormatter,
private val fileExtensionExtractor: FileExtensionExtractor,
) : LocalMediaFactory {
override fun createFromMediaFile(mediaFile: MediaFile, mediaInfo: MediaInfo): LocalMedia {
val uri = mediaFile.toFile().toUri()
return createFromUri(
uri = uri,
mimeType = mediaInfo.mimeType,
name = mediaInfo.name,
formattedFileSize = mediaInfo.formattedFileSize,
)
}
override fun createFromUri(
uri: Uri,
mimeType: String?,
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))
val fileExtension = fileExtensionExtractor.extractFromName(fileName)
return LocalMedia(
uri = uri,
info = MediaInfo(
mimeType = resolvedMimeType,
name = fileName,
formattedFileSize = fileSize,
fileExtension = fileExtension
)
)
}
}

View file

@ -1,44 +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 androidx.compose.runtime.Composable
interface LocalMediaActions {
@Composable
fun Configure()
/**
* Will save the current media to the Downloads directory.
* The [LocalMedia.uri] needs to have a file scheme.
*/
suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit>
/**
* Will try to find a suitable application to share the media with.
* The [LocalMedia.uri] needs to have a file scheme.
*/
suspend fun share(localMedia: LocalMedia): Result<Unit>
/**
* Will try to find a suitable application to open the media with.
* The [LocalMedia.uri] needs to have a file scheme.
*/
suspend fun open(localMedia: LocalMedia): Result<Unit>
}

View file

@ -1,42 +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.net.Uri
import io.element.android.libraries.matrix.api.media.MediaFile
interface LocalMediaFactory {
/**
* This method will create a [LocalMedia] with the given [MediaFile] and [MediaInfo].
*/
fun createFromMediaFile(
mediaFile: MediaFile,
mediaInfo: MediaInfo,
): LocalMedia
/**
* This method will create a [LocalMedia] with the given mimeType, name and formattedFileSize
* If any of those params are null, it'll try to read them from the content.
*/
fun createFromUri(
uri: Uri,
mimeType: String?,
name: String?,
formattedFileSize: String?
): LocalMedia
}

View file

@ -1,270 +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.annotation.SuppressLint
import android.net.Uri
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.media.helper.formatFileExtensionAndSize
import io.element.android.features.messages.impl.media.local.exoplayer.ExoPlayerWrapper
import io.element.android.features.messages.impl.media.local.pdf.PdfViewer
import io.element.android.features.messages.impl.media.local.pdf.rememberPdfViewerState
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
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.CommonDrawables
import io.element.android.libraries.designsystem.utils.KeepScreenOn
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.ui.strings.CommonStrings
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.ZoomableState
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
import me.saket.telephoto.zoomable.rememberZoomableImageState
import me.saket.telephoto.zoomable.rememberZoomableState
@SuppressLint("UnsafeOptInUsageError")
@Composable
fun LocalMediaView(
localMedia: LocalMedia?,
modifier: Modifier = Modifier,
localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
mediaInfo: MediaInfo? = localMedia?.info,
) {
val zoomableState = rememberZoomableState(
zoomSpec = ZoomSpec(maxZoomFactor = 5f)
)
val mimeType = mediaInfo?.mimeType
when {
mimeType.isMimeTypeImage() -> MediaImageView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
zoomableState = zoomableState,
modifier = modifier
)
mimeType.isMimeTypeVideo() -> MediaVideoView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
modifier = modifier
)
mimeType == MimeTypes.Pdf -> MediaPDFView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
zoomableState = zoomableState,
modifier = modifier
)
//TODO handle audio with exoplayer
else -> MediaFileView(
localMediaViewState = localMediaViewState,
uri = localMedia?.uri,
info = mediaInfo,
modifier = modifier
)
}
}
@Composable
private fun MediaImageView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
zoomableState: ZoomableState,
modifier: Modifier = Modifier,
) {
if (LocalInspectionMode.current) {
Image(
painter = painterResource(id = CommonDrawables.sample_background),
modifier = modifier.fillMaxSize(),
contentDescription = null,
)
} else {
val zoomableImageState = rememberZoomableImageState(zoomableState)
localMediaViewState.isReady = zoomableImageState.isImageDisplayed
ZoomableAsyncImage(
modifier = modifier.fillMaxSize(),
state = zoomableImageState,
model = localMedia?.uri,
contentDescription = stringResource(id = CommonStrings.common_image),
contentScale = ContentScale.Fit,
)
}
}
@UnstableApi
@Composable
private fun MediaVideoView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val playerListener = object : Player.Listener {
override fun onRenderedFirstFrame() {
localMediaViewState.isReady = true
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
localMediaViewState.isPlaying = isPlaying
}
}
val exoPlayer = remember {
ExoPlayerWrapper.create(context)
.apply {
addListener(playerListener)
this.prepare()
}
}
if (localMedia?.uri != null) {
LaunchedEffect(localMedia.uri) {
val mediaItem = MediaItem.fromUri(localMedia.uri)
exoPlayer.setMediaItem(mediaItem)
}
} else {
exoPlayer.setMediaItems(emptyList())
}
KeepScreenOn(localMediaViewState.isPlaying)
AndroidView(
factory = {
PlayerView(context).apply {
player = exoPlayer
setShowPreviousButton(false)
setShowNextButton(false)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
controllerShowTimeoutMs = 3000
}
},
modifier = modifier.fillMaxSize()
)
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> exoPlayer.play()
Lifecycle.Event.ON_PAUSE -> exoPlayer.pause()
Lifecycle.Event.ON_DESTROY -> {
exoPlayer.release()
exoPlayer.removeListener(playerListener)
}
else -> Unit
}
}
}
@Composable
private fun MediaPDFView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
zoomableState: ZoomableState,
modifier: Modifier = Modifier,
) {
val pdfViewerState = rememberPdfViewerState(
model = localMedia?.uri,
zoomableState = zoomableState
)
localMediaViewState.isReady = pdfViewerState.isLoaded
PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier)
}
@Composable
private fun MediaFileView(
localMediaViewState: LocalMediaViewState,
uri: Uri?,
info: MediaInfo?,
modifier: Modifier = Modifier,
) {
val isAudio = info?.mimeType.isMimeTypeAudio().orFalse()
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 = if (isAudio) Icons.Outlined.GraphicEq else null,
resourceId = if (isAudio) null else CommonDrawables.ic_attachment,
contentDescription = null,
tint = MaterialTheme.colorScheme.background,
modifier = Modifier
.size(32.dp)
.rotate(if (isAudio) 0f else -45f),
)
}
if (info != null) {
Spacer(modifier = Modifier.height(20.dp))
Text(
text = info.name,
maxLines = 2,
style = ElementTheme.typography.fontBodyLgRegular,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatFileExtensionAndSize(info.fileExtension, info.formattedFileSize),
style = ElementTheme.typography.fontBodyMdRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}

View file

@ -1,37 +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 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)
var isPlaying: Boolean by mutableStateOf(false)
}
@Composable
fun rememberLocalMediaViewState(): LocalMediaViewState {
return remember {
LocalMediaViewState()
}
}

View file

@ -1,49 +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.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,
val fileExtension: String,
) : Parcelable
fun anImageInfo(): MediaInfo = MediaInfo(
"an image file.jpg", MimeTypes.Jpeg, "4MB", "jpg"
)
fun aVideoInfo(): MediaInfo = MediaInfo(
"a video file.mp4", MimeTypes.Mp4, "14MB", "mp4"
)
fun aPdfInfo(): MediaInfo = MediaInfo(
"a pdf file.pdf", MimeTypes.Pdf, "23MB", "pdf"
)
fun aFileInfo(): MediaInfo = MediaInfo(
"an apk file.apk", MimeTypes.Apk, "50MB", "apk"
)
fun anAudioInfo(): MediaInfo = MediaInfo(
"an audio file.mp3", MimeTypes.Mp3, "7MB", "mp3"
)

View file

@ -1,49 +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.exoplayer
import android.content.Context
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
/**
* Wrapper around ExoPlayer to disable some commands.
* Necessary to hide the settings wheels from the player.
*/
@UnstableApi
class ExoPlayerWrapper(private val exoPlayer: ExoPlayer) : ExoPlayer by exoPlayer {
override fun isCommandAvailable(command: Int): Boolean {
return availableCommands.contains(command)
}
override fun getAvailableCommands(): Player.Commands {
return exoPlayer.availableCommands
.buildUpon()
.remove(Player.COMMAND_SET_TRACK_SELECTION_PARAMETERS)
.build()
}
companion object {
fun create(context: Context): ExoPlayer {
return ExoPlayerWrapper(
ExoPlayer.Builder(context).build()
)
}
}
}

View file

@ -1,33 +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.pdf
import android.content.Context
import android.net.Uri
import android.os.ParcelFileDescriptor
import java.io.File
class ParcelFileDescriptorFactory(private val context: Context) {
fun create(model: Any?) = runCatching {
when (model) {
is File -> ParcelFileDescriptor.open(model, ParcelFileDescriptor.MODE_READ_ONLY)
is Uri -> context.contentResolver.openFileDescriptor(model, "r")!!
else -> error(RuntimeException("Can't handle this model"))
}
}
}

View file

@ -1,111 +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.pdf
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.pdf.PdfRenderer
import androidx.compose.runtime.Stable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@Stable
class PdfPage(
maxWidth: Int,
val pageIndex: Int,
private val mutex: Mutex,
private val pdfRenderer: PdfRenderer,
private val coroutineScope: CoroutineScope,
) {
sealed interface State {
data class Loading(val width: Int, val height: Int) : State
data class Loaded(val bitmap: Bitmap) : State
}
private val renderWidth = maxWidth
private val renderHeight: Int
private var loadJob: Job? = null
init {
// We are just opening and closing the page to extract data so we can build the Loading state with the correct dimensions.
pdfRenderer.openPage(pageIndex).use { page ->
renderHeight = (page.height * (renderWidth.toFloat() / page.width)).toInt()
}
}
private val mutableStateFlow = MutableStateFlow<State>(
State.Loading(
width = renderWidth,
height = renderHeight
)
)
val stateFlow: StateFlow<State> = mutableStateFlow
fun load() {
loadJob = coroutineScope.launch {
val bitmap = mutex.withLock {
withContext(Dispatchers.IO) {
pdfRenderer.openPageRenderAndClose(pageIndex, renderWidth, renderHeight)
}
}
mutableStateFlow.value = State.Loaded(bitmap)
}
}
fun close() {
loadJob?.cancel()
when (val loadingState = stateFlow.value) {
is State.Loading -> return
is State.Loaded -> {
loadingState.bitmap.recycle()
mutableStateFlow.value = State.Loading(
width = renderWidth,
height = renderHeight
)
}
}
}
private fun PdfRenderer.openPageRenderAndClose(index: Int, bitmapWidth: Int, bitmapHeight: Int): Bitmap {
fun createBitmap(bitmapWidth: Int, bitmapHeight: Int): Bitmap {
val bitmap = Bitmap.createBitmap(
bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
canvas.drawColor(Color.WHITE)
canvas.drawBitmap(bitmap, 0f, 0f, null)
return bitmap
}
return openPage(index).use { page ->
createBitmap(bitmapWidth, bitmapHeight).apply {
page.render(this, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
}
}
}
}

View file

@ -1,74 +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.pdf
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class PdfRendererManager(
private val parcelFileDescriptor: ParcelFileDescriptor,
private val width: Int,
private val coroutineScope: CoroutineScope,
) {
private val mutex = Mutex()
private var pdfRenderer: PdfRenderer? = null
private val mutablePdfPages = MutableStateFlow<List<PdfPage>>(emptyList())
val pdfPages: StateFlow<List<PdfPage>> = mutablePdfPages
fun open() {
coroutineScope.launch {
mutex.withLock {
withContext(Dispatchers.IO) {
pdfRenderer = PdfRenderer(parcelFileDescriptor).apply {
// Preload just 3 pages so we can render faster
val firstPages = loadPages(from = 0, to = 3)
mutablePdfPages.value = firstPages
val nextPages = loadPages(from = 3, to = pageCount)
mutablePdfPages.value = firstPages + nextPages
}
}
}
}
}
fun close() {
coroutineScope.launch {
mutex.withLock {
mutablePdfPages.value.forEach { pdfPage ->
pdfPage.close()
}
pdfRenderer?.close()
parcelFileDescriptor.close()
}
}
}
private fun PdfRenderer.loadPages(from: Int, to: Int): List<PdfPage> {
return (from until minOf(to, pageCount)).map { pageIndex ->
PdfPage(width, pageIndex, mutex, this, coroutineScope)
}
}
}

View file

@ -1,117 +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.pdf
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import me.saket.telephoto.zoomable.zoomable
@Composable
fun PdfViewer(
pdfViewerState: PdfViewerState,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(
modifier = modifier.zoomable(pdfViewerState.zoomableState),
contentAlignment = Alignment.Center
) {
val maxWidthInPx = maxWidth.roundToPx()
DisposableEffect(pdfViewerState) {
pdfViewerState.openForWidth(maxWidthInPx)
onDispose {
pdfViewerState.close()
}
}
val pdfPages = pdfViewerState.getPages()
PdfPagesView(pdfPages.toImmutableList(), pdfViewerState.lazyListState)
}
}
@Composable
private fun PdfPagesView(
pdfPages: ImmutableList<PdfPage>,
lazyListState: LazyListState,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically)
) {
items(pdfPages.size) { index ->
val pdfPage = pdfPages[index]
PdfPageView(pdfPage)
}
}
}
@Composable
private fun PdfPageView(
pdfPage: PdfPage,
modifier: Modifier = Modifier,
) {
val pdfPageState by pdfPage.stateFlow.collectAsState()
DisposableEffect(pdfPage) {
pdfPage.load()
onDispose {
pdfPage.close()
}
}
when (val state = pdfPageState) {
is PdfPage.State.Loaded -> {
Image(
bitmap = state.bitmap.asImageBitmap(),
contentDescription = stringResource(id = CommonStrings.a11y_page_n, pdfPage.pageIndex),
contentScale = ContentScale.FillWidth,
modifier = modifier.fillMaxWidth()
)
}
is PdfPage.State.Loading -> {
Box(
modifier = modifier
.fillMaxWidth()
.height(state.height.toDp())
.background(color = Color.White)
)
}
}
}

View file

@ -1,87 +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.pdf
import android.content.Context
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.CoroutineScope
import me.saket.telephoto.zoomable.ZoomableState
import me.saket.telephoto.zoomable.rememberZoomableState
@Stable
class PdfViewerState(
private val model: Any?,
private val coroutineScope: CoroutineScope,
private val context: Context,
val zoomableState: ZoomableState,
val lazyListState: LazyListState,
) {
var isLoaded by mutableStateOf(false)
private var pdfRendererManager by mutableStateOf<PdfRendererManager?>(null)
@Composable
fun getPages(): List<PdfPage>{
return pdfRendererManager?.run {
pdfPages.collectAsState().value
}?: emptyList()
}
fun openForWidth(maxWidth: Int) {
ParcelFileDescriptorFactory(context).create(model)
.onSuccess {
pdfRendererManager = PdfRendererManager(it, maxWidth, coroutineScope).apply {
open()
}
isLoaded = true
}
}
fun close() {
pdfRendererManager?.close()
isLoaded = false
}
}
@Composable
fun rememberPdfViewerState(
model: Any?,
zoomableState: ZoomableState = rememberZoomableState(),
lazyListState: LazyListState = rememberLazyListState(),
context: Context = LocalContext.current,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
): PdfViewerState {
return remember(model) {
PdfViewerState(
model = model,
coroutineScope = coroutineScope,
context = context,
zoomableState = zoomableState,
lazyListState = lazyListState
)
}
}

View file

@ -1,25 +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.viewer
sealed interface MediaViewerEvents {
data object SaveOnDisk: MediaViewerEvents
data object Share: MediaViewerEvents
data object OpenWith: MediaViewerEvents
data object RetryLoading : MediaViewerEvents
data object ClearLoadingError : MediaViewerEvents
}

View file

@ -1,62 +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.viewer
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ForcedDarkElementTheme
import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.media.MediaSource
@ContributesNode(RoomScope::class)
class MediaViewerNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: MediaViewerPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(inputs)
@Composable
override fun View(modifier: Modifier) {
ForcedDarkElementTheme {
val state = presenter.present()
MediaViewerView(
state = state,
modifier = modifier,
onBackPressed = this::navigateUp
)
}
}
}

View file

@ -1,166 +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.viewer
import android.content.ActivityNotFoundException
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.features.messages.impl.media.local.LocalMediaActions
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import io.element.android.libraries.androidutils.R as UtilsR
class MediaViewerPresenter @AssistedInject constructor(
@Assisted private val inputs: MediaViewerNode.Inputs,
private val localMediaFactory: LocalMediaFactory,
private val mediaLoader: MatrixMediaLoader,
private val localMediaActions: LocalMediaActions,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<MediaViewerState> {
@AssistedFactory
interface Factory {
fun create(inputs: MediaViewerNode.Inputs): MediaViewerPresenter
}
@Composable
override fun present(): MediaViewerState {
val coroutineScope = rememberCoroutineScope()
var loadMediaTrigger by remember { mutableIntStateOf(0) }
val mediaFile: MutableState<MediaFile?> = remember {
mutableStateOf(null)
}
val localMedia: MutableState<Async<LocalMedia>> = remember {
mutableStateOf(Async.Uninitialized)
}
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
localMediaActions.Configure()
DisposableEffect(loadMediaTrigger) {
coroutineScope.downloadMedia(mediaFile, localMedia)
onDispose {
mediaFile.value?.close()
}
}
fun handleEvents(mediaViewerEvents: MediaViewerEvents) {
when (mediaViewerEvents) {
MediaViewerEvents.RetryLoading -> loadMediaTrigger++
MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized
MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value)
MediaViewerEvents.Share -> coroutineScope.share(localMedia.value)
MediaViewerEvents.OpenWith -> coroutineScope.open(localMedia.value)
}
}
return MediaViewerState(
mediaInfo = inputs.mediaInfo,
thumbnailSource = inputs.thumbnailSource,
downloadedMedia = localMedia.value,
snackbarMessage = snackbarMessage,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.downloadMedia(mediaFile: MutableState<MediaFile?>, localMedia: MutableState<Async<LocalMedia>>) = launch {
localMedia.value = Async.Loading()
mediaLoader.downloadMediaFile(
source = inputs.mediaSource,
mimeType = inputs.mediaInfo.mimeType,
body = inputs.mediaInfo.name
)
.onSuccess {
mediaFile.value = it
}
.mapCatching { mediaFile ->
localMediaFactory.createFromMediaFile(
mediaFile = mediaFile,
mediaInfo = inputs.mediaInfo
)
}
.onSuccess {
localMedia.value = Async.Success(it)
}
.onFailure {
localMedia.value = Async.Failure(it)
}
}
private fun CoroutineScope.saveOnDisk(localMedia: Async<LocalMedia>) = launch {
if (localMedia is Async.Success) {
localMediaActions.saveOnDisk(localMedia.data)
.onSuccess {
val snackbarMessage = SnackbarMessage(CommonStrings.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.data)
.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.data)
.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 {
CommonStrings.error_unknown
}
}
}

View file

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

View file

@ -1,85 +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.viewer
import android.net.Uri
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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.anAudioInfo
import io.element.android.features.messages.impl.media.local.anImageInfo
import io.element.android.libraries.architecture.Async
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
override val values: Sequence<MediaViewerState>
get() = sequenceOf(
aMediaViewerState(),
aMediaViewerState(Async.Loading()),
aMediaViewerState(Async.Failure(IllegalStateException("error"))),
aMediaViewerState(
Async.Success(
LocalMedia(Uri.EMPTY, anImageInfo())
),
anImageInfo(),
),
aMediaViewerState(
Async.Success(
LocalMedia(Uri.EMPTY, aVideoInfo())
),
aVideoInfo(),
),
aMediaViewerState(
Async.Success(
LocalMedia(Uri.EMPTY, aPdfInfo())
),
aPdfInfo(),
),
aMediaViewerState(
Async.Loading(),
aFileInfo(),
),
aMediaViewerState(
Async.Success(
LocalMedia(Uri.EMPTY, aFileInfo())
),
aFileInfo(),
),
aMediaViewerState(
Async.Loading(),
anAudioInfo(),
),
aMediaViewerState(
Async.Success(
LocalMedia(Uri.EMPTY, anAudioInfo())
),
anAudioInfo(),
),
)
}
fun aMediaViewerState(
downloadedMedia: Async<LocalMedia> = Async.Uninitialized,
mediaInfo: MediaInfo = anImageInfo(),
) = MediaViewerState(
mediaInfo = mediaInfo,
thumbnailSource = null,
downloadedMedia = downloadedMedia,
snackbarMessage = null
) {}

View file

@ -1,272 +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.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.messages.impl.media.viewer
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import io.element.android.features.messages.impl.R
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.MediaInfo
import io.element.android.features.messages.impl.media.local.rememberLocalMediaViewState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.delay
@Composable
fun MediaViewerView(
state: MediaViewerState,
onBackPressed: () -> Unit,
modifier: Modifier = Modifier,
) {
fun onRetry() {
state.eventSink(MediaViewerEvents.RetryLoading)
}
fun onDismissError() {
state.eventSink(MediaViewerEvents.ClearLoadingError)
}
val localMediaViewState = rememberLocalMediaViewState()
val showThumbnail = !localMediaViewState.isReady
val showProgress = rememberShowProgress(state.downloadedMedia)
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Scaffold(
modifier,
topBar = {
MediaViewerTopBar(
actionsEnabled = state.downloadedMedia is Async.Success,
mimeType = state.mediaInfo.mimeType,
onBackPressed = onBackPressed,
eventSink = state.eventSink
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(it),
) {
if (showProgress) {
LinearProgressIndicator(
Modifier
.fillMaxWidth()
.height(2.dp)
)
} else {
Spacer(Modifier.height(2.dp))
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (state.downloadedMedia is Async.Failure) {
ErrorView(
errorMessage = stringResource(id = CommonStrings.error_unknown),
onRetry = ::onRetry,
onDismiss = ::onDismissError
)
}
LocalMediaView(
localMediaViewState = localMediaViewState,
localMedia = state.downloadedMedia.dataOrNull(),
mediaInfo = state.mediaInfo,
)
ThumbnailView(
mediaInfo = state.mediaInfo,
thumbnailSource = state.thumbnailSource,
showThumbnail = showThumbnail,
)
}
}
}
}
@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,
mimeType: String,
onBackPressed: () -> Unit,
eventSink: (MediaViewerEvents) -> Unit,
) {
TopAppBar(
title = {},
navigationIcon = { BackButton(onClick = onBackPressed) },
actions = {
IconButton(
enabled = actionsEnabled,
onClick = {
eventSink(MediaViewerEvents.OpenWith)
},
) {
when (mimeType) {
MimeTypes.Apk -> Icon(
resourceId = R.drawable.ic_apk_install,
contentDescription = stringResource(id = CommonStrings.common_install_apk_android)
)
else -> Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = stringResource(id = CommonStrings.action_open_with)
)
}
}
IconButton(
enabled = actionsEnabled,
onClick = {
eventSink(MediaViewerEvents.SaveOnDisk)
},
) {
Icon(
resourceId = CompoundDrawables.ic_download,
contentDescription = stringResource(id = CommonStrings.action_save),
)
}
IconButton(
enabled = actionsEnabled,
onClick = {
eventSink(MediaViewerEvents.Share)
},
) {
Icon(
resourceId = CompoundDrawables.ic_share_android,
contentDescription = stringResource(id = CommonStrings.action_share)
)
}
}
)
}
@Composable
private fun ThumbnailView(
thumbnailSource: MediaSource?,
showThumbnail: Boolean,
mediaInfo: MediaInfo,
) {
AnimatedVisibility(
visible = showThumbnail,
enter = fadeIn(),
exit = fadeOut()
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
val mediaRequestData = MediaRequestData(
source = thumbnailSource,
kind = MediaRequestData.Kind.File(mediaInfo.name, mediaInfo.mimeType)
)
AsyncImage(
modifier = Modifier.fillMaxSize(),
model = mediaRequestData,
alpha = 0.8f,
contentScale = ContentScale.Fit,
contentDescription = null,
)
}
}
}
@Composable
private fun ErrorView(
errorMessage: String,
onRetry: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
RetryDialog(
modifier = modifier,
content = errorMessage,
onRetry = onRetry,
onDismiss = onDismiss
)
}
// Only preview in dark, dark theme is forced on the Node.
@Preview
@Composable
internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark {
MediaViewerView(
state = state,
onBackPressed = {}
)
}

View file

@ -34,7 +34,6 @@ import androidx.media3.common.util.UnstableApi
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor
import io.element.android.libraries.architecture.Presenter
@ -51,6 +50,7 @@ import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.textcomposer.model.Message
@ -72,6 +72,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import kotlin.coroutines.coroutineContext
import kotlin.time.Duration.Companion.seconds
@ -432,6 +433,7 @@ class MessageComposerPresenter @Inject constructor(
attachmentState.value = AttachmentsState.None
}
.onFailure { cause ->
Timber.e(cause, "Failed to send attachment")
attachmentState.value = AttachmentsState.None
if (cause is CancellationException) {
throw cause

View file

@ -34,6 +34,7 @@ import im.vector.app.features.analytics.plan.PollEnd
import im.vector.app.features.analytics.plan.PollVote
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
@ -98,7 +99,7 @@ class TimelinePresenter @AssistedInject constructor(
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
val hasNewItems = remember { mutableStateOf(false) }
val newItemState = remember { mutableStateOf(NewEventState.None) }
val sessionVerifiedStatus by verificationService.sessionVerifiedStatus.collectAsState()
val keyBackupState by encryptionService.backupStateStateFlow.collectAsState()
@ -121,7 +122,7 @@ class TimelinePresenter @AssistedInject constructor(
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
is TimelineEvents.OnScrollFinished -> {
if (event.firstIndex == 0) {
hasNewItems.value = false
newItemState.value = NewEventState.None
}
appScope.sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex,
@ -150,7 +151,7 @@ class TimelinePresenter @AssistedInject constructor(
}
LaunchedEffect(timelineItems.size) {
computeHasNewItems(timelineItems, prevMostRecentItemId, hasNewItems)
computeNewItemState(timelineItems, prevMostRecentItemId, newItemState)
}
LaunchedEffect(Unit) {
@ -182,7 +183,7 @@ class TimelinePresenter @AssistedInject constructor(
paginationState = paginationState,
timelineItems = timelineItems,
showReadReceipts = readReceiptsEnabled,
hasNewItems = hasNewItems.value,
newEventState = newItemState.value,
sessionState = sessionState,
eventSink = ::handleEvents
)
@ -191,22 +192,32 @@ class TimelinePresenter @AssistedInject constructor(
/**
* This method compute the hasNewItem state passed as a [MutableState] each time the timeline items size changes.
* Basically, if we got new timeline event from sync or local, either from us or another user, we update the state so we tell we have new items.
* The state never goes back to false from this method, but need to be reset from somewhere else.
* The state never goes back to None from this method, but need to be reset from somewhere else.
*/
private suspend fun computeHasNewItems(
private suspend fun computeNewItemState(
timelineItems: ImmutableList<TimelineItem>,
prevMostRecentItemId: MutableState<String?>,
hasNewItemsState: MutableState<Boolean>
newEventState: MutableState<NewEventState>
) = withContext(dispatchers.computation) {
// FromMe is prioritized over FromOther, so skip if we already have a FromMe
if (newEventState.value == NewEventState.FromMe) {
return@withContext
}
val newMostRecentItem = timelineItems.firstOrNull()
val prevMostRecentItemIdValue = prevMostRecentItemId.value
val newMostRecentItemId = newMostRecentItem?.identifier()
val hasNewItems = prevMostRecentItemIdValue != null &&
val hasNewEvent = prevMostRecentItemIdValue != null &&
newMostRecentItem is TimelineItem.Event &&
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
newMostRecentItemId != prevMostRecentItemIdValue
if (hasNewItems) {
hasNewItemsState.value = true
if (hasNewEvent) {
val newMostRecentEvent = newMostRecentItem as? TimelineItem.Event
val fromMe = newMostRecentEvent?.localSendState != null
newEventState.value = if (fromMe) {
NewEventState.FromMe
} else {
NewEventState.FromOther
}
}
prevMostRecentItemId.value = newMostRecentItemId
}

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.libraries.matrix.api.core.EventId
@ -30,7 +31,7 @@ data class TimelineState(
val highlightedEventId: EventId?,
val userHasPermissionToSendMessage: Boolean,
val paginationState: MatrixTimeline.PaginationState,
val hasNewItems: Boolean,
val newEventState: NewEventState,
val sessionState: SessionState,
val eventSink: (TimelineEvents) -> Unit
)

View file

@ -16,7 +16,9 @@
package io.element.android.features.messages.impl.timeline
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
@ -53,7 +55,7 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf
),
highlightedEventId = null,
userHasPermissionToSendMessage = true,
hasNewItems = false,
newEventState = NewEventState.None,
sessionState = aSessionState(
isSessionVerified = true,
isKeyBackupEnabled = true,
@ -186,17 +188,27 @@ internal fun aTimelineItemReadReceipts(): TimelineItemReadReceipts {
)
}
fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents {
val event = aTimelineItemEvent(
internal fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents {
val event1 = aTimelineItemEvent(
isMine = true,
content = aTimelineItemStateEventContent(),
groupPosition = TimelineItemGroupPosition.None
groupPosition = TimelineItemGroupPosition.None,
readReceiptState = TimelineItemReadReceipts(
receipts = listOf(aReadReceiptData(0)).toPersistentList(),
),
)
val event2 = aTimelineItemEvent(
isMine = true,
content = aTimelineItemStateEventContent(body = "Another state event"),
groupPosition = TimelineItemGroupPosition.None,
readReceiptState = TimelineItemReadReceipts(
receipts = listOf(aReadReceiptData(1)).toPersistentList(),
),
)
val events = listOf(event1, event2)
return TimelineItem.GroupedEvents(
id = id.toString(),
events = listOf(
event,
event,
).toImmutableList()
events = events.toImmutableList(),
aggregatedReadReceipts = events.flatMap { it.readReceiptState.receipts }.toImmutableList(),
)
}

View file

@ -20,13 +20,11 @@ package io.element.android.features.messages.impl.timeline
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@ -42,36 +40,27 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.components.TimelineItemEventRow
import io.element.android.features.messages.impl.timeline.components.TimelineItemStateEventRow
import io.element.android.features.messages.impl.timeline.components.TimelineItemVirtualRow
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemRoomBeginningView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
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.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.libraries.designsystem.animation.alphaAnimation
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -167,141 +156,44 @@ fun TimelineView(
TimelineScrollHelper(
isTimelineEmpty = state.timelineItems.isEmpty(),
lazyListState = lazyListState,
hasNewItems = state.hasNewItems,
newEventState = state.newEventState,
onScrollFinishedAt = ::onScrollFinishedAt
)
}
}
@Composable
private fun TimelineItemRow(
timelineItem: TimelineItem,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
highlightedItem: String?,
userHasPermissionToSendMessage: Boolean,
sessionState: SessionState,
onUserDataClick: (UserId) -> Unit,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier
) {
when (timelineItem) {
is TimelineItem.Virtual -> {
TimelineItemVirtualRow(
virtual = timelineItem,
sessionState = sessionState,
modifier = modifier,
)
}
is TimelineItem.Event -> {
if (timelineItem.content is TimelineItemStateContent) {
TimelineItemStateEventRow(
event = timelineItem,
isHighlighted = highlightedItem == timelineItem.identifier(),
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
eventSink = eventSink,
modifier = modifier,
)
} else {
TimelineItemEventRow(
event = timelineItem,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = highlightedItem == timelineItem.identifier(),
canReply = userHasPermissionToSendMessage && timelineItem.content.canBeRepliedTo(),
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
onUserDataClick = onUserDataClick,
inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onTimestampClicked = onTimestampClicked,
onSwipeToReply = { onSwipeToReply(timelineItem) },
eventSink = eventSink,
modifier = modifier,
)
}
}
is TimelineItem.GroupedEvents -> {
val isExpanded = rememberSaveable(key = timelineItem.identifier()) { mutableStateOf(false) }
fun onExpandGroupClick() {
isExpanded.value = !isExpanded.value
}
Column(modifier = modifier.animateContentSize()) {
GroupHeaderView(
text = pluralStringResource(
id = R.plurals.room_timeline_state_changes,
count = timelineItem.events.size,
timelineItem.events.size
),
isExpanded = isExpanded.value,
isHighlighted = !isExpanded.value && timelineItem.events.any { it.identifier() == highlightedItem },
onClick = ::onExpandGroupClick,
)
if (isExpanded.value) {
Column {
timelineItem.events.forEach { subGroupEvent ->
TimelineItemRow(
timelineItem = subGroupEvent,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem,
sessionState = sessionState,
userHasPermissionToSendMessage = false,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
eventSink = eventSink,
onSwipeToReply = {},
)
}
}
}
}
}
}
}
@Composable
private fun BoxScope.TimelineScrollHelper(
isTimelineEmpty: Boolean,
lazyListState: LazyListState,
hasNewItems: Boolean,
newEventState: NewEventState,
onScrollFinishedAt: (Int) -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
val canAutoScroll by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 3 } }
val canAutoScroll by remember {
derivedStateOf {
lazyListState.firstVisibleItemIndex < 3
}
}
LaunchedEffect(canAutoScroll, hasNewItems) {
val shouldAutoScroll = isScrollFinished && canAutoScroll && hasNewItems
if (shouldAutoScroll) {
coroutineScope.launch {
fun scrollToBottom() {
coroutineScope.launch {
if (lazyListState.firstVisibleItemIndex > 10) {
lazyListState.scrollToItem(0)
} else {
lazyListState.animateScrollToItem(0)
}
}
}
LaunchedEffect(canAutoScroll, newEventState) {
val shouldAutoScroll = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe)
if (shouldAutoScroll) {
scrollToBottom()
}
}
LaunchedEffect(isScrollFinished, isTimelineEmpty) {
if (isScrollFinished && !isTimelineEmpty) {
// Notify the parent composable about the first visible item index when scrolling finishes
@ -315,15 +207,7 @@ private fun BoxScope.TimelineScrollHelper(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 12.dp),
onClick = {
coroutineScope.launch {
if (lazyListState.firstVisibleItemIndex > 10) {
lazyListState.scrollToItem(0)
} else {
lazyListState.animateScrollToItem(0)
}
}
}
onClick = ::scrollToBottom,
)
}

View file

@ -59,6 +59,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.constraintlayout.compose.ConstrainScope
import androidx.constraintlayout.compose.ConstraintLayout
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
@ -66,6 +67,7 @@ import io.element.android.features.messages.impl.timeline.components.event.toExt
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
import io.element.android.features.messages.impl.timeline.model.InReplyToMetadata
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
@ -75,6 +77,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.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.metadata
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -89,18 +92,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
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.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
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.compound.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
import kotlin.math.abs
@ -171,8 +163,6 @@ fun TimelineItemEventRow(
state = state.draggableState,
),
event = event,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
@ -183,7 +173,6 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) },
onReadReceiptsClicked = { onReadReceiptClick(event) },
eventSink = eventSink,
)
}
@ -191,8 +180,6 @@ fun TimelineItemEventRow(
} else {
TimelineItemEventRowContent(
event = event,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
@ -203,10 +190,20 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) },
onReadReceiptsClicked = { onReadReceiptClick(event) },
eventSink = eventSink,
)
}
// Read receipts / Send state
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = event.localSendState,
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
showReadReceipts = showReadReceipts,
onReadReceiptsClicked = { onReadReceiptClick(event) },
modifier = Modifier.padding(top = 4.dp),
)
}
}
@ -236,8 +233,6 @@ private fun SwipeSensitivity(
@Composable
private fun TimelineItemEventRowContent(
event: TimelineItem.Event,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
isHighlighted: Boolean,
interactionSource: MutableInteractionSource,
onClick: () -> Unit,
@ -246,7 +241,6 @@ private fun TimelineItemEventRowContent(
inReplyToClicked: () -> Unit,
onUserDataClicked: () -> Unit,
onReactionClicked: (emoji: String) -> Unit,
onReadReceiptsClicked: () -> Unit,
onReactionLongClicked: (emoji: String) -> Unit,
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents) -> Unit,
@ -267,7 +261,6 @@ private fun TimelineItemEventRowContent(
sender,
message,
reactions,
readReceipts,
) = createRefs()
// Sender
@ -334,25 +327,6 @@ private fun TimelineItemEventRowContent(
.padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp)
)
}
// Read receipts / Send state
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = event.localSendState,
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
showReadReceipts = showReadReceipts,
onReadReceiptsClicked = onReadReceiptsClicked,
modifier = Modifier
.constrainAs(readReceipts) {
if (event.reactionsState.reactions.isNotEmpty()) {
top.linkTo(reactions.bottom, margin = 4.dp)
} else {
top.linkTo(message.bottom, margin = 4.dp)
}
}
)
}
}
@ -538,13 +512,10 @@ private fun MessageEventBubbleContent(
}
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
val senderName = inReplyTo.senderDisplayName ?: inReplyTo.senderId.value
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyTo)
val text = textForInReplyTo(inReplyTo)
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
ReplyToContent(
senderName = senderName,
text = text,
attachmentThumbnailInfo = attachmentThumbnailInfo,
metadata = inReplyTo.metadata(),
modifier = Modifier
.padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(6.dp))
@ -585,11 +556,10 @@ private fun MessageEventBubbleContent(
@Composable
private fun ReplyToContent(
senderName: String,
text: String?,
attachmentThumbnailInfo: AttachmentThumbnailInfo?,
metadata: InReplyToMetadata?,
modifier: Modifier = Modifier,
) {
val paddings = if (attachmentThumbnailInfo != null) {
val paddings = if (metadata is InReplyToMetadata.Thumbnail) {
PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp)
} else {
PaddingValues(horizontal = 12.dp, vertical = 4.dp)
@ -599,9 +569,9 @@ private fun ReplyToContent(
.background(MaterialTheme.colorScheme.surface)
.padding(paddings)
) {
if (attachmentThumbnailInfo != null) {
if (metadata is InReplyToMetadata.Thumbnail) {
AttachmentThumbnail(
info = attachmentThumbnailInfo,
info = metadata.attachmentThumbnailInfo,
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.size(36.dp)
@ -619,7 +589,7 @@ private fun ReplyToContent(
overflow = TextOverflow.Ellipsis,
)
Text(
text = text.orEmpty(),
text = metadata?.text.orEmpty(),
style = ElementTheme.typography.fontBodyMdRegular,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.secondary,
@ -630,60 +600,6 @@ private fun ReplyToContent(
}
}
private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyToDetails): AttachmentThumbnailInfo? {
return when (val eventContent = inReplyTo.eventContent) {
is MessageContent -> when (val type = eventContent.type) {
is ImageMessageType -> AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource ?: type.source,
textContent = eventContent.body,
type = AttachmentThumbnailType.Image,
blurHash = type.info?.blurhash,
)
is VideoMessageType -> AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource,
textContent = eventContent.body,
type = AttachmentThumbnailType.Video,
blurHash = type.info?.blurhash,
)
is FileMessageType -> AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource,
textContent = eventContent.body,
type = AttachmentThumbnailType.File,
)
is LocationMessageType -> AttachmentThumbnailInfo(
textContent = eventContent.body,
type = AttachmentThumbnailType.Location,
)
is AudioMessageType -> AttachmentThumbnailInfo(
textContent = eventContent.body,
type = AttachmentThumbnailType.Audio,
)
is VoiceMessageType -> AttachmentThumbnailInfo(
type = AttachmentThumbnailType.Voice,
)
else -> null
}
is PollContent -> AttachmentThumbnailInfo(
textContent = eventContent.question,
type = AttachmentThumbnailType.Poll,
)
else -> null
}
}
@Composable
private fun textForInReplyTo(inReplyTo: InReplyToDetails): String {
return when (val eventContent = inReplyTo.eventContent) {
is MessageContent -> when (eventContent.type) {
is LocationMessageType -> stringResource(CommonStrings.common_shared_location)
is VoiceMessageType -> stringResource(CommonStrings.common_voice_message)
else -> inReplyTo.textContent ?: eventContent.body
}
is PollContent -> eventContent.question
else -> ""
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowPreview() = ElementPreview {

View file

@ -45,6 +45,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
@PreviewsDayNight
@Composable
@ -127,9 +129,9 @@ class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
question = "Poll which are being replied.",
kind = PollKind.Disclosed,
maxSelections = 1u,
answers = emptyList(),
votes = emptyMap(),
endTime = null,
answers = persistentListOf(),
votes = persistentMapOf(),
endTime = null
isEdited = false,
),
).map {

View file

@ -0,0 +1,203 @@
/*
* 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.animation.animateContentSize
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.aGroupedEvents
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.timeline.session.aSessionState
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
@Composable
fun TimelineItemGroupedEventsRow(
timelineItem: TimelineItem.GroupedEvents,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
highlightedItem: String?,
sessionState: SessionState,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier
) {
val isExpanded = rememberSaveable(key = timelineItem.identifier()) { mutableStateOf(false) }
fun onExpandGroupClick() {
isExpanded.value = !isExpanded.value
}
TimelineItemGroupedEventsRowContent(
isExpanded = isExpanded.value,
onExpandGroupClick = ::onExpandGroupClick,
timelineItem = timelineItem,
highlightedItem = highlightedItem,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
sessionState = sessionState,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
eventSink = eventSink,
modifier = modifier,
)
}
@Composable
private fun TimelineItemGroupedEventsRowContent(
isExpanded: Boolean,
onExpandGroupClick: () -> Unit,
timelineItem: TimelineItem.GroupedEvents,
highlightedItem: String?,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
sessionState: SessionState,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.animateContentSize()) {
GroupHeaderView(
text = pluralStringResource(
id = R.plurals.room_timeline_state_changes,
count = timelineItem.events.size,
timelineItem.events.size
),
isExpanded = isExpanded,
isHighlighted = !isExpanded && timelineItem.events.any { it.identifier() == highlightedItem },
onClick = onExpandGroupClick,
)
if (isExpanded) {
Column {
timelineItem.events.forEach { subGroupEvent ->
TimelineItemRow(
timelineItem = subGroupEvent,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem,
sessionState = sessionState,
userHasPermissionToSendMessage = false,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
eventSink = eventSink,
onSwipeToReply = {},
)
}
}
} else if (showReadReceipts) {
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = null,
isLastOutgoingMessage = false,
receipts = timelineItem.aggregatedReadReceipts,
),
showReadReceipts = true,
onReadReceiptsClicked = onExpandGroupClick
)
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPreview {
TimelineItemGroupedEventsRowContent(
isExpanded = true,
onExpandGroupClick = {},
timelineItem = aGroupedEvents(),
highlightedItem = null,
showReadReceipts = true,
isLastOutgoingMessage = false,
sessionState = aSessionState(),
onClick = {},
onLongClick = {},
inReplyToClick = {},
onUserDataClick = {},
onTimestampClicked = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
eventSink = {},
)
}
@PreviewsDayNight
@Composable
internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPreview {
TimelineItemGroupedEventsRowContent(
isExpanded = false,
onExpandGroupClick = {},
timelineItem = aGroupedEvents(),
highlightedItem = null,
showReadReceipts = true,
isLastOutgoingMessage = false,
sessionState = aSessionState(),
onClick = {},
onLongClick = {},
inReplyToClick = {},
onUserDataClick = {},
onTimestampClicked = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
eventSink = {},
)
}

View file

@ -0,0 +1,114 @@
/*
* 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.TimelineEvents
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
@Composable
internal fun TimelineItemRow(
timelineItem: TimelineItem,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
highlightedItem: String?,
userHasPermissionToSendMessage: Boolean,
sessionState: SessionState,
onUserDataClick: (UserId) -> Unit,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier
) {
when (timelineItem) {
is TimelineItem.Virtual -> {
TimelineItemVirtualRow(
virtual = timelineItem,
sessionState = sessionState,
modifier = modifier,
)
}
is TimelineItem.Event -> {
if (timelineItem.content is TimelineItemStateContent) {
TimelineItemStateEventRow(
event = timelineItem,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = highlightedItem == timelineItem.identifier(),
onClick = { onClick(timelineItem) },
onReadReceiptsClick = onReadReceiptClick,
onLongClick = { onLongClick(timelineItem) },
eventSink = eventSink,
modifier = modifier,
)
} else {
TimelineItemEventRow(
event = timelineItem,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = highlightedItem == timelineItem.identifier(),
canReply = userHasPermissionToSendMessage && timelineItem.content.canBeRepliedTo(),
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
onUserDataClick = onUserDataClick,
inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onTimestampClicked = onTimestampClicked,
onSwipeToReply = { onSwipeToReply(timelineItem) },
eventSink = eventSink,
modifier = modifier,
)
}
}
is TimelineItem.GroupedEvents -> {
TimelineItemGroupedEventsRow(
timelineItem = timelineItem,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem,
sessionState = sessionState,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
eventSink = eventSink,
modifier = modifier,
)
}
}
}

View file

@ -18,6 +18,7 @@ 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.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
@ -32,51 +33,73 @@ import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.event.noExtraPadding
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import kotlinx.collections.immutable.toPersistentList
@Composable
fun TimelineItemStateEventRow(
event: TimelineItem.Event,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
isHighlighted: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
onReadReceiptsClick: (event: TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
Box(
Column(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.wrapContentHeight(),
contentAlignment = Alignment.Center
) {
MessageStateEventContainer(
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
Box(
modifier = Modifier
.zIndex(-1f)
.widthIn(max = 320.dp)
.fillMaxWidth()
.padding(top = 8.dp, bottom = 2.dp)
.wrapContentHeight(),
contentAlignment = Alignment.Center
) {
TimelineItemEventContentView(
content = event.content,
isMine = event.isMine,
isEditable = event.isEditable,
MessageStateEventContainer(
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
extraPadding = noExtraPadding,
eventSink = eventSink,
modifier = Modifier.defaultTimelineContentPadding()
)
modifier = Modifier
.zIndex(-1f)
.widthIn(max = 320.dp)
) {
TimelineItemEventContentView(
content = event.content,
isMine = event.isMine,
isEditable = event.isEditable,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
extraPadding = noExtraPadding,
eventSink = eventSink,
modifier = Modifier.defaultTimelineContentPadding()
)
}
}
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = event.localSendState,
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
showReadReceipts = showReadReceipts,
onReadReceiptsClicked = { onReadReceiptsClick(event) },
)
}
}
@ -87,11 +110,17 @@ internal fun TimelineItemStateEventRowPreview() = ElementPreview {
event = aTimelineItemEvent(
isMine = false,
content = aTimelineItemStateEventContent(),
groupPosition = TimelineItemGroupPosition.None
groupPosition = TimelineItemGroupPosition.None,
readReceiptState = TimelineItemReadReceipts(
receipts = listOf(aReadReceiptData(0)).toPersistentList(),
)
),
showReadReceipts = true,
isLastOutgoingMessage = false,
isHighlighted = false,
onClick = {},
onLongClick = {},
onReadReceiptsClick = {},
eventSink = {}
)
}

View file

@ -16,6 +16,8 @@
package io.element.android.features.messages.impl.timeline.components.group
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -26,6 +28,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -76,9 +79,17 @@ fun GroupHeaderView(
color = MaterialTheme.colorScheme.secondary,
style = ElementTheme.typography.fontBodyMdRegular,
)
val rotation: Float by animateFloatAsState(
targetValue = if (isExpanded) 90f else 0f,
animationSpec = tween(
delayMillis = 0,
durationMillis = 300,
),
label = "chevron"
)
Icon(
modifier = Modifier.rotate(if (isExpanded) 180f else 0f),
imageVector = CompoundIcons.ChevronDown,
modifier = Modifier.rotate(rotation),
imageVector = CompoundIcons.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary
)

View file

@ -17,7 +17,6 @@
package io.element.android.features.messages.impl.timeline.components.reactionsummary
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
@ -38,10 +37,6 @@ class ReactionSummaryPresenter @Inject constructor(
) : Presenter<ReactionSummaryState> {
@Composable
override fun present(): ReactionSummaryState {
LaunchedEffect(Unit) {
room.updateMembers()
}
val membersState by room.membersStateFlow.collectAsState()
val target: MutableState<ReactionSummaryState.Summary?> = remember {

View file

@ -33,23 +33,23 @@ class ReadReceiptViewStateProvider : PreviewParameterProvider<ReadReceiptViewSta
aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId"))),
aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(1) { add(aReadReceiptData(it)) } },
receipts = List(1) { aReadReceiptData(it) },
),
aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(2) { add(aReadReceiptData(it)) } },
receipts = List(2) { aReadReceiptData(it) },
),
aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(3) { add(aReadReceiptData(it)) } },
receipts = List(3) { aReadReceiptData(it) },
),
aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(4) { add(aReadReceiptData(it)) } },
receipts = List(4) { aReadReceiptData(it) },
),
aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(5) { add(aReadReceiptData(it)) } },
receipts = List(5) { aReadReceiptData(it) },
),
)
}

View file

@ -18,9 +18,10 @@ package io.element.android.features.messages.impl.timeline.components.receipt.bo
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@ -29,6 +30,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -38,7 +40,6 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@ -83,34 +84,39 @@ internal fun ReadReceiptBottomSheet(
}
@Composable
private fun ColumnScope.ReadReceiptBottomSheetContent(
private fun ReadReceiptBottomSheetContent(
state: ReadReceiptBottomSheetState,
onUserDataClicked: (UserId) -> Unit,
) {
ListItem(
headlineContent = {
Text(text = stringResource(id = CommonStrings.common_seen_by))
LazyColumn {
item {
ListItem(
headlineContent = {
Text(text = stringResource(id = CommonStrings.common_seen_by))
}
)
}
items(
items = state.selectedEvent?.readReceiptState?.receipts.orEmpty()
) {
val userId = UserId(it.avatarData.id)
MatrixUserRow(
modifier = Modifier.clickable { onUserDataClicked(userId) },
matrixUser = MatrixUser(
userId = userId,
displayName = it.avatarData.name,
avatarUrl = it.avatarData.url,
),
avatarSize = AvatarSize.ReadReceiptList,
trailingContent = {
Text(
text = it.formattedDate,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
)
}
)
val receipts = state.selectedEvent?.readReceiptState?.receipts.orEmpty()
receipts.forEach {
val userId = UserId(it.avatarData.id)
MatrixUserRow(
modifier = Modifier.clickable { onUserDataClicked(userId) },
matrixUser = MatrixUser(
userId = userId,
displayName = it.avatarData.name,
avatarUrl = it.avatarData.url,
),
avatarSize = AvatarSize.ReadReceiptList,
trailingContent = {
Text(
text = it.formattedDate,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
)
}
}

View file

@ -27,7 +27,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -45,14 +44,15 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.ui.messages.toHtmlDocument
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.time.Duration
import javax.inject.Inject
import kotlin.time.Duration
class TimelineItemContentMessageFactory @Inject constructor(
private val fileSizeFormatter: FileSizeFormatter,
private val fileExtensionExtractor: FileExtensionExtractor,
private val fileExtensionExtractor: io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor,
private val featureFlagService: FeatureFlagService,
) {
@ -104,7 +104,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(),
duration = messageType.info?.duration?.toMillis() ?: 0L,
duration = messageType.info?.duration ?: Duration.ZERO,
blurHash = messageType.info?.blurhash,
aspectRatio = aspectRatio,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),

View file

@ -73,7 +73,8 @@ private fun MutableList<TimelineItem>.addGroup(
add(
TimelineItem.GroupedEvents(
id = groupId,
events = groupOfItems.toImmutableList()
events = groupOfItems.toImmutableList(),
aggregatedReadReceipts = groupOfItems.flatMap { it.readReceiptState.receipts }.toImmutableList()
)
)
}

View file

@ -0,0 +1,108 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.model
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
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.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
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.CommonStrings
@Immutable
internal sealed interface InReplyToMetadata {
val text: String?
data class Thumbnail(
val attachmentThumbnailInfo: AttachmentThumbnailInfo
) : InReplyToMetadata {
override val text: String? = attachmentThumbnailInfo.textContent
}
data class Text(
override val text: String
) : InReplyToMetadata
}
/**
* Computes metadata for the in reply to message.
*
* Metadata can be either a thumbnail with a text OR just a text.
*/
@Composable
internal fun InReplyToDetails.metadata(): InReplyToMetadata? = when (eventContent) {
is MessageContent -> when (val type = eventContent.type) {
is ImageMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource ?: type.source,
textContent = eventContent.body,
type = AttachmentThumbnailType.Image,
blurHash = type.info?.blurhash,
)
)
is VideoMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource,
textContent = eventContent.body,
type = AttachmentThumbnailType.Video,
blurHash = type.info?.blurhash,
)
)
is FileMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource,
textContent = eventContent.body,
type = AttachmentThumbnailType.File,
)
)
is LocationMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
textContent = stringResource(CommonStrings.common_shared_location),
type = AttachmentThumbnailType.Location,
)
)
is AudioMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
textContent = eventContent.body,
type = AttachmentThumbnailType.Audio,
)
)
is VoiceMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
textContent = stringResource(CommonStrings.common_voice_message),
type = AttachmentThumbnailType.Voice,
)
)
else -> InReplyToMetadata.Text(textContent ?: eventContent.body)
}
is PollContent -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
textContent = eventContent.question,
type = AttachmentThumbnailType.Poll,
)
)
else -> null
}

View file

@ -14,16 +14,12 @@
* limitations under the License.
*/
package io.element.android.features.messages.impl.media.local
package io.element.android.features.messages.impl.timeline.model
import android.net.Uri
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import kotlinx.parcelize.Parcelize
@Parcelize
@Immutable
data class LocalMedia(
val uri: Uri,
val info: MediaInfo,
) : Parcelable
/**
* Model if there is a new event in the timeline and if it is from me or from other.
* This can be used to scroll to the bottom of the list when a new event is added.
*/
enum class NewEventState {
None, FromMe, FromOther
}

View file

@ -88,6 +88,6 @@ sealed interface TimelineItem {
data class GroupedEvents(
val id: String,
val events: ImmutableList<Event>,
val aggregatedReadReceipts: ImmutableList<ReadReceiptData>,
) : TimelineItem
}

View file

@ -1,28 +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.timeline.model
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
internal class TimelineItemGroupPositionProvider : PreviewParameterProvider<TimelineItemGroupPosition> {
override val values = sequenceOf(
TimelineItemGroupPosition.First,
TimelineItemGroupPosition.Middle,
TimelineItemGroupPosition.Last,
TimelineItemGroupPosition.None,
)
}

View file

@ -16,9 +16,8 @@
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 java.time.Duration
import kotlin.time.Duration
data class TimelineItemAudioContent(
val body: String,
@ -29,6 +28,10 @@ data class TimelineItemAudioContent(
val fileExtension: String,
) : TimelineItemEventContent {
val fileExtensionAndSize = formatFileExtensionAndSize(fileExtension, formattedFileSize)
val fileExtensionAndSize =
io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize(
fileExtension,
formattedFileSize
)
override val type: String = "TimelineItemAudioContent"
}

View file

@ -19,7 +19,7 @@ package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource
import java.time.Duration
import kotlin.time.Duration.Companion.milliseconds
open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineItemAudioContent> {
override val values: Sequence<TimelineItemAudioContent>
@ -35,6 +35,6 @@ fun aTimelineItemAudioContent(fileName: String = "A sound.mp3") = TimelineItemAu
mimeType = MimeTypes.Pdf,
formattedFileSize = "100kB",
fileExtension = "mp3",
duration = Duration.ofMillis(100),
duration = 100.milliseconds,
mediaSource = MediaSource(""),
)

View file

@ -79,6 +79,8 @@ fun aTimelineItemTextContent() = TimelineItemTextContent(
fun aTimelineItemUnknownContent() = TimelineItemUnknownContent
fun aTimelineItemStateEventContent() = TimelineItemStateEventContent(
body = "A state event",
fun aTimelineItemStateEventContent(
body: String = "A state event",
) = TimelineItemStateEventContent(
body = body,
)

View file

@ -16,8 +16,8 @@
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.mediaviewer.api.helper.formatFileExtensionAndSize
data class TimelineItemFileContent(
val body: String,

View file

@ -17,10 +17,11 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.media.MediaSource
import kotlin.time.Duration
data class TimelineItemVideoContent(
val body: String,
val duration: Long,
val duration: Duration,
val videoSource: MediaSource,
val thumbnailSource: MediaSource?,
val aspectRatio: Float?,

View file

@ -20,6 +20,7 @@ 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.ui.components.A_BLUR_HASH
import kotlin.time.Duration.Companion.milliseconds
open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineItemVideoContent> {
override val values: Sequence<TimelineItemVideoContent>
@ -35,7 +36,7 @@ fun aTimelineItemVideoContent() = TimelineItemVideoContent(
thumbnailSource = null,
blurHash = A_BLUR_HASH,
aspectRatio = 0.5f,
duration = 100,
duration = 100.milliseconds,
videoSource = MediaSource(""),
height = 300,
width = 150,

View file

@ -19,7 +19,7 @@ package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import kotlinx.collections.immutable.ImmutableList
import java.time.Duration
import kotlin.time.Duration
data class TimelineItemVoiceContent(
val eventId: EventId?,

View file

@ -21,21 +21,23 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import kotlinx.collections.immutable.toPersistentList
import java.time.Duration
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
open class TimelineItemVoiceContentProvider : PreviewParameterProvider<TimelineItemVoiceContent> {
override val values: Sequence<TimelineItemVoiceContent>
get() = sequenceOf(
aTimelineItemVoiceContent(
durationMs = 1,
duration = 1.milliseconds,
waveform = listOf(),
),
aTimelineItemVoiceContent(
durationMs = 10_000,
duration = 10_000.milliseconds,
waveform = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f),
),
aTimelineItemVoiceContent(
durationMs = 1_800_000, // 30 minutes
duration = 30.minutes,
waveform = List(1024) { it / 1024f },
),
)
@ -44,14 +46,14 @@ open class TimelineItemVoiceContentProvider : PreviewParameterProvider<TimelineI
fun aTimelineItemVoiceContent(
eventId: String? = "\$anEventId",
body: String = "body doesn't really matter for a voice message",
durationMs: Long = 61_000,
duration: Duration = 61_000.milliseconds,
contentUri: String = "mxc://matrix.org/1234567890abcdefg",
mimeType: String = MimeTypes.Ogg,
waveform: List<Float> = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f),
) = TimelineItemVoiceContent(
eventId = eventId?.let { EventId(it) },
body = body,
duration = Duration.ofMillis(durationMs),
duration = duration,
mediaSource = MediaSource(contentUri),
mimeType = mimeType,
waveform = waveform.toPersistentList(),

View file

@ -1,45 +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.timeline.util
import android.webkit.MimeTypeMap
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
interface FileExtensionExtractor {
fun extractFromName(name: String): String
}
@ContributesBinding(AppScope::class)
class FileExtensionExtractorWithValidation @Inject constructor() : FileExtensionExtractor {
override fun extractFromName(name: String): String {
val fileExtension = name.substringAfterLast('.', "")
// Makes sure the extension is known by the system, otherwise default to binary extension.
return if (MimeTypeMap.getSingleton().hasExtension(fileExtension)) {
fileExtension
} else {
"bin"
}
}
}
class FileExtensionExtractorWithoutValidation : FileExtensionExtractor {
override fun extractFromName(name: String): String {
return name.substringAfterLast('.', "")
}
}

View file

@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.voicemessages.composer
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@ -41,7 +42,7 @@ class VoiceMessageComposerPlayer @Inject constructor(
private val coroutineScope: CoroutineScope,
) {
companion object {
const val MIME_TYPE = "audio/ogg"
const val MIME_TYPE = MimeTypes.Ogg
}
private var mediaPath: String? = null
@ -201,6 +202,7 @@ class VoiceMessageComposerPlayer @Inject constructor(
progress = 0f,
)
}
/**
* Whether this player is currently playing.
*/
@ -212,7 +214,6 @@ class VoiceMessageComposerPlayer @Inject constructor(
val isStopped get() = this.playState == PlayState.Stopped
}
enum class PlayState {
/**
* The player is stopped, i.e. it has just been initialised.

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.voicemessages.timeline
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
@ -196,7 +197,7 @@ class DefaultVoiceMessagePlayer(
mediaPlayer.setMedia(
uri = mediaFile.path,
mediaId = eventId.value,
mimeType = "audio/ogg", // Files in the voice cache have no extension so we need to set the mime type manually.
mimeType = MimeTypes.Ogg, // Files in the voice cache have no extension so we need to set the mime type manually.
startPositionMs = if (state.isEnded) 0L else state.currentPosition,
)
}

View file

@ -98,7 +98,7 @@ class VoiceMessagePresenter @AssistedInject constructor(
}
}
val duration by remember {
derivedStateOf { playerState.duration ?: content.duration.toMillis() }
derivedStateOf { playerState.duration ?: content.duration.inWholeMilliseconds }
}
val progress by remember {
derivedStateOf {

View file

@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M160,880Q127,880 103.5,856.5Q80,833 80,800L80,160Q80,127 103.5,103.5Q127,80 160,80L480,80L720,320L720,490L640,490L640,360L440,360L440,160L160,160Q160,160 160,160Q160,160 160,160L160,800Q160,800 160,800Q160,800 160,800L600,800L600,880L160,880ZM160,800L160,490L160,490L160,360L160,160L160,160Q160,160 160,160Q160,160 160,160L160,800Q160,800 160,800Q160,800 160,800L160,800ZM200,760Q204,711 230,670Q256,629 298,605L260,537Q260,536 264,522Q269,520 273.5,520Q278,520 280,525L319,595Q339,587 359,582.5Q379,578 400,578Q421,578 441,582.5Q461,587 481,595L520,525Q520,525 535,521Q540,523 541,528Q542,533 540,537L502,605Q544,629 570,670Q596,711 600,760L200,760ZM310,700Q318,700 324,694Q330,688 330,680Q330,672 324,666Q318,660 310,660Q302,660 296,666Q290,672 290,680Q290,688 296,694Q302,700 310,700ZM490,700Q498,700 504,694Q510,688 510,680Q510,672 504,666Q498,660 490,660Q482,660 476,666Q470,672 470,680Q470,688 476,694Q482,700 490,700ZM800,880L640,720L696,663L760,726L760,560L840,560L840,726L904,663L960,720L800,880Z"/>
</vector>

View file

@ -45,6 +45,7 @@
<string name="screen_room_notification_settings_error_loading_settings">"Při načítání nastavení oznámení došlo k chybě."</string>
<string name="screen_room_notification_settings_error_restoring_default">"Obnovení výchozího režimu se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Nastavení režimu se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_room_notification_settings_mentions_only_disclaimer">"Váš domovský server tuto možnost nepodporuje v šifrovaných místnostech, v této místnosti nebudete dostávat upozornění."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Všechny zprávy"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"V této místnosti mě upozornit na"</string>
<string name="screen_room_reactions_show_less">"Zobrazit méně"</string>

View file

@ -44,6 +44,7 @@
<string name="screen_room_notification_settings_error_loading_settings">"Une erreur sest produite lors du chargement des paramètres de notification."</string>
<string name="screen_room_notification_settings_error_restoring_default">"Échec de la restauration du mode par défaut, veuillez réessayer."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Échec de la configuration du mode, veuillez réessayer."</string>
<string name="screen_room_notification_settings_mentions_only_disclaimer">"Votre serveur daccueil ne supporte pas cette option pour les salons chiffrés, vous ne serez pas notifié(e) dans ce salon."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Tous les messages"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"Dans ce salon, prévenez-moi pour"</string>
<string name="screen_room_reactions_show_less">"Afficher moins"</string>

View file

@ -27,7 +27,6 @@ import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.media.FakeLocalMediaFactory
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.messagesummary.FakeMessageSummaryFormatter
@ -80,6 +79,7 @@ import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
@ -92,12 +92,14 @@ import io.element.android.tests.testutils.consumeItemsUntilTimeout
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.element.android.tests.testutils.waitForPredicate
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
@Suppress("LargeClass")
class MessagesPresenterTest {
@get:Rule
@ -125,6 +127,21 @@ class MessagesPresenterTest {
}
}
@Test
fun `present - call is disabled if user cannot join it even if there is an ongoing call`() = runTest {
val room = FakeMatrixRoom().apply {
givenCanUserJoinCall(Result.success(false))
givenRoomInfo(aRoomInfo(hasRoomCall = true))
}
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = consumeItemsUntilTimeout().last()
assertThat(initialState.callState).isEqualTo(RoomCallState.DISABLED)
}
}
@Test
fun `present - handle toggling a reaction`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
@ -264,7 +281,7 @@ class MessagesPresenterTest {
val mediaMessage = aMessageEvent(
content = TimelineItemVideoContent(
body = "video.mp4",
duration = 10L,
duration = 10.milliseconds,
videoSource = MediaSource(AN_AVATAR_URL),
thumbnailSource = MediaSource(AN_AVATAR_URL),
mimeType = MimeTypes.Mp4,
@ -462,7 +479,7 @@ class MessagesPresenterTest {
val room = FakeMatrixRoom(sessionId = A_SESSION_ID)
room.givenRoomMembersState(
MatrixRoomMembersState.Ready(
listOf(
persistentListOf(
aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN),
aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE),
)
@ -489,7 +506,7 @@ class MessagesPresenterTest {
room.givenRoomMembersState(
MatrixRoomMembersState.Error(
failure = Throwable(),
prevRoomMembers = listOf(
prevRoomMembers = persistentListOf(
aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN),
aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE),
)
@ -535,7 +552,7 @@ class MessagesPresenterTest {
val room = FakeMatrixRoom(sessionId = A_SESSION_ID)
room.givenRoomMembersState(
MatrixRoomMembersState.Ready(
listOf(
persistentListOf(
aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN),
aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE),
)
@ -655,6 +672,7 @@ class MessagesPresenterTest {
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
currentSessionIdHolder: CurrentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
): MessagesPresenter {
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
@ -723,6 +741,7 @@ class MessagesPresenterTest {
featureFlagsService = FakeFeatureFlagService(),
buildMeta = aBuildMeta(),
dispatchers = coroutineDispatchers,
currentSessionIdHolder = currentSessionIdHolder,
)
}
}

View file

@ -26,13 +26,13 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
import io.element.android.features.messages.impl.attachments.preview.SendActionState
import io.element.android.features.messages.impl.fixtures.aLocalMedia
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.tests.testutils.WarmUpRule
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi

View file

@ -16,22 +16,10 @@
package io.element.android.features.messages.impl.fixtures
import android.net.Uri
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.features.messages.impl.media.local.anImageInfo
fun aLocalMedia(
uri: Uri,
mediaInfo: MediaInfo = anImageInfo(),
) = LocalMedia(
uri = uri,
info = mediaInfo
)
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media(
localMedia = localMedia,
compressIfPossible = compressIfPossible,
)

View file

@ -32,7 +32,6 @@ import io.element.android.features.messages.impl.timeline.factories.event.Timeli
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractorWithoutValidation
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
@ -40,6 +39,7 @@ import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope

View file

@ -1,56 +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
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.tests.testutils.simulateLongTask
class FakeLocalMediaActions : LocalMediaActions {
var shouldFail = false
@Composable
override fun Configure() {
//NOOP
}
override suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit> = simulateLongTask {
if (shouldFail) {
Result.failure(RuntimeException())
} else {
Result.success(Unit)
}
}
override suspend fun share(localMedia: LocalMedia): Result<Unit> = simulateLongTask {
if (shouldFail) {
Result.failure(RuntimeException())
} else {
Result.success(Unit)
}
}
override suspend fun open(localMedia: LocalMedia): Result<Unit> = simulateLongTask {
if (shouldFail) {
Result.failure(RuntimeException())
} else {
Result.success(Unit)
}
}
}

View file

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

View file

@ -1,164 +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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.messages.impl.media.viewer
import android.net.Uri
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.media.FakeLocalMediaActions
import io.element.android.features.messages.impl.media.FakeLocalMediaFactory
import io.element.android.features.messages.impl.media.local.aFileInfo
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.tests.testutils.WarmUpRule
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
private val TESTED_MEDIA_INFO = aFileInfo()
class MediaViewerPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
private val mockMediaUri: Uri = mockk("localMediaUri")
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri)
@Test
fun `present - download media success scenario`() = runTest {
val mediaLoader = FakeMediaLoader()
val mediaActions = FakeLocalMediaActions()
val presenter = createMediaViewerPresenter(mediaLoader, mediaActions)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
var state = awaitItem()
assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized)
assertThat(state.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
state = awaitItem()
assertThat(state.downloadedMedia).isInstanceOf(Async.Loading::class.java)
state = awaitItem()
val successData = state.downloadedMedia.dataOrNull()
assertThat(state.downloadedMedia).isInstanceOf(Async.Success::class.java)
assertThat(successData).isNotNull()
}
}
@Test
fun `present - check all actions `() = runTest {
val mediaLoader = FakeMediaLoader()
val mediaActions = FakeLocalMediaActions()
val snackbarDispatcher = SnackbarDispatcher()
val presenter = createMediaViewerPresenter(mediaLoader, mediaActions, snackbarDispatcher)
moleculeFlow(RecompositionMode.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()
snackbarDispatcher.clear()
assertThat(awaitItem().snackbarMessage).isNull()
// Check failures
mediaActions.shouldFail = true
state.eventSink(MediaViewerEvents.OpenWith)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
snackbarDispatcher.clear()
assertThat(awaitItem().snackbarMessage).isNull()
state.eventSink(MediaViewerEvents.Share)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
snackbarDispatcher.clear()
assertThat(awaitItem().snackbarMessage).isNull()
state.eventSink(MediaViewerEvents.SaveOnDisk)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
}
}
@Test
fun `present - download media failure then retry with success scenario`() = runTest {
val mediaLoader = FakeMediaLoader()
val mediaActions = FakeLocalMediaActions()
val presenter = createMediaViewerPresenter(mediaLoader, mediaActions)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
mediaLoader.shouldFail = true
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized)
assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
val loadingState = awaitItem()
assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
val failureState = awaitItem()
assertThat(failureState.downloadedMedia).isInstanceOf(Async.Failure::class.java)
mediaLoader.shouldFail = false
failureState.eventSink(MediaViewerEvents.RetryLoading)
//There is one recomposition because of the retry mechanism
skipItems(1)
val retryLoadingState = awaitItem()
assertThat(retryLoadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
val successState = awaitItem()
val successData = successState.downloadedMedia.dataOrNull()
assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java)
assertThat(successData).isNotNull()
}
}
private fun createMediaViewerPresenter(
mediaLoader: FakeMediaLoader,
localMediaActions: FakeLocalMediaActions,
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
): MediaViewerPresenter {
return MediaViewerPresenter(
inputs = MediaViewerNode.Inputs(
mediaInfo = TESTED_MEDIA_INFO,
mediaSource = aMediaSource(),
thumbnailSource = null
),
localMediaFactory = localMediaFactory,
mediaLoader = mediaLoader,
localMediaActions = localMediaActions,
snackbarDispatcher = snackbarDispatcher,
)
}
}

View file

@ -26,7 +26,6 @@ import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.impl.media.FakeLocalMediaFactory
import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
@ -67,6 +66,7 @@ import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
@ -78,11 +78,11 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.waitForPredicate
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import okhttp3.internal.immutableListOf
import org.junit.Rule
import org.junit.Test
import uniffi.wysiwyg_composer.MentionsState
@ -734,7 +734,7 @@ class MessageComposerPresenterTest {
isOneToOne = false,
).apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(
immutableListOf(currentUser, invitedUser, bob, david),
persistentListOf(currentUser, invitedUser, bob, david),
))
givenCanTriggerRoomNotification(Result.success(true))
}
@ -798,7 +798,7 @@ class MessageComposerPresenterTest {
isOneToOne = true,
).apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(
immutableListOf(currentUser, invitedUser, bob, david),
persistentListOf(currentUser, invitedUser, bob, david),
))
givenCanTriggerRoomNotification(Result.success(true))
}

View file

@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.FakeMessagesNavigator
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
@ -36,6 +37,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -48,9 +50,12 @@ import io.element.android.libraries.matrix.test.verification.FakeSessionVerifica
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.awaitWithLatch
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.element.android.tests.testutils.waitForPredicate
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
@ -187,28 +192,49 @@ class TimelinePresenterTest {
}
@Test
fun `present - covers hasNewItems scenarios`() = runTest {
fun `present - covers newEventState scenarios`() = runTest {
val timeline = FakeMatrixTimeline()
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.hasNewItems).isFalse()
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
assertThat(initialState.timelineItems.size).isEqualTo(0)
timeline.updateTimelineItems {
listOf(MatrixTimelineItem.Event(0, anEventTimelineItem(content = aMessageContent())))
}
skipItems(1)
assertThat(awaitItem().timelineItems.size).isEqualTo(1)
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
// Mimics sending a message, and assert newEventState is FromMe
timeline.updateTimelineItems { items ->
items + listOf(MatrixTimelineItem.Event(1, anEventTimelineItem(content = aMessageContent())))
val event = anEventTimelineItem(content = aMessageContent(), localSendState = LocalEventSendState.Sent(AN_EVENT_ID))
items + listOf(MatrixTimelineItem.Event(1, event))
}
skipItems(1)
assertThat(awaitItem().timelineItems.size).isEqualTo(2)
assertThat(awaitItem().hasNewItems).isTrue()
consumeItemsUntilPredicate { it.timelineItems.size == 2 }
awaitLastSequentialItem().also { state ->
assertThat(state.newEventState).isEqualTo(NewEventState.FromMe)
}
// Mimics receiving a message without clearing the previous FromMe
timeline.updateTimelineItems { items ->
val event = anEventTimelineItem(content = aMessageContent())
items + listOf(MatrixTimelineItem.Event(2, event))
}
consumeItemsUntilPredicate { it.timelineItems.size == 3 }
// Scroll to bottom to clear previous FromMe
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
assertThat(awaitItem().hasNewItems).isFalse()
awaitLastSequentialItem().also { state ->
assertThat(state.newEventState).isEqualTo(NewEventState.None)
}
// Mimics receiving a message and assert newEventState is FromOther
timeline.updateTimelineItems { items ->
val event = anEventTimelineItem(content = aMessageContent())
items + listOf(MatrixTimelineItem.Event(3, event))
}
consumeItemsUntilPredicate { it.timelineItems.size == 4 }
awaitLastSequentialItem().also { state ->
assertThat(state.newEventState).isEqualTo(NewEventState.FromOther)
}
cancelAndIgnoreRemainingEvents()
}
}
@ -221,7 +247,7 @@ class TimelinePresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.hasNewItems).isFalse()
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
assertThat(initialState.timelineItems.size).isEqualTo(0)
val now = Date().time
val minuteInMillis = 60 * 1000
@ -229,18 +255,18 @@ class TimelinePresenterTest {
val (alice, bob, charlie) = aMatrixUserList().take(3).mapIndexed { i, user ->
ReactionSender(senderId = user.userId, timestamp = now + i * minuteInMillis)
}
val oneReaction = listOf(
val oneReaction = persistentListOf(
EventReaction(
key = "❤️",
senders = listOf(alice, charlie)
senders = persistentListOf(alice, charlie)
),
EventReaction(
key = "👍",
senders = listOf(alice, bob)
senders = persistentListOf(alice, bob)
),
EventReaction(
key = "🐶",
senders = listOf(charlie)
senders = persistentListOf(charlie)
),
)
timeline.updateTimelineItems {

View file

@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@ -42,7 +43,7 @@ class ReactionSummaryPresenterTests {
private val roomMember = aRoomMember(userId = A_USER_ID, avatarUrl = AN_AVATAR_URL, displayName = A_USER_NAME)
private val summaryEvent = ReactionSummaryEvents.ShowReactionSummary(AN_EVENT_ID, listOf(aggregatedReaction), aggregatedReaction.key)
private val room = FakeMatrixRoom().apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember)))
givenRoomMembersState(MatrixRoomMembersState.Ready(persistentListOf(roomMember)))
}
private val presenter = ReactionSummaryPresenter(room)

View file

@ -27,7 +27,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractorWithoutValidation
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -55,11 +54,13 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.time.Duration
import java.time.Duration.ofMinutes
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
class TimelineItemContentMessageFactoryTest {
@ -140,14 +141,14 @@ class TimelineItemContentMessageFactoryTest {
)
val expected = TimelineItemVideoContent(
body = "body",
duration = 0,
duration = Duration.ZERO,
videoSource = MediaSource(url = "url", json = null),
thumbnailSource = null,
aspectRatio = null,
blurHash = null,
height = null,
width = null,
mimeType = "application/octet-stream",
mimeType = MimeTypes.OctetStream,
formattedFileSize = "0 Bytes",
fileExtension = "",
)
@ -163,7 +164,7 @@ class TimelineItemContentMessageFactoryTest {
body = "body.mp4",
source = MediaSource("url"),
info = VideoInfo(
duration = ofMinutes(1),
duration = 1.minutes,
height = 100,
width = 300,
mimetype = MimeTypes.Mp4,
@ -184,7 +185,7 @@ class TimelineItemContentMessageFactoryTest {
)
val expected = TimelineItemVideoContent(
body = "body.mp4",
duration = 60_000,
duration = 1.minutes,
videoSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"),
aspectRatio = 3f,
@ -210,7 +211,7 @@ class TimelineItemContentMessageFactoryTest {
body = "body",
duration = Duration.ZERO,
mediaSource = MediaSource(url = "url", json = null),
mimeType = "application/octet-stream",
mimeType = MimeTypes.OctetStream,
formattedFileSize = "0 Bytes",
fileExtension = "",
)
@ -226,7 +227,7 @@ class TimelineItemContentMessageFactoryTest {
body = "body.mp3",
source = MediaSource("url"),
info = AudioInfo(
duration = ofMinutes(1),
duration = 1.minutes,
size = 123L,
mimetype = MimeTypes.Mp3,
)
@ -237,7 +238,7 @@ class TimelineItemContentMessageFactoryTest {
)
val expected = TimelineItemAudioContent(
body = "body.mp3",
duration = ofMinutes(1),
duration = 1.minutes,
mediaSource = MediaSource(url = "url", json = null),
mimeType = MimeTypes.Mp3,
formattedFileSize = "123 Bytes",
@ -259,7 +260,7 @@ class TimelineItemContentMessageFactoryTest {
body = "body",
duration = Duration.ZERO,
mediaSource = MediaSource(url = "url", json = null),
mimeType = "application/octet-stream",
mimeType = MimeTypes.OctetStream,
waveform = emptyList<Float>().toImmutableList()
)
assertThat(result).isEqualTo(expected)
@ -274,12 +275,13 @@ class TimelineItemContentMessageFactoryTest {
body = "body.ogg",
source = MediaSource("url"),
info = AudioInfo(
duration = ofMinutes(1),
duration = 1.minutes,
size = 123L,
mimetype = MimeTypes.Ogg,
),
details = AudioDetails(
duration = ofMinutes(1), waveform = listOf(1f, 2f)
duration = 1.minutes,
waveform = persistentListOf(1f, 2f),
),
)
),
@ -289,10 +291,10 @@ class TimelineItemContentMessageFactoryTest {
val expected = TimelineItemVoiceContent(
eventId = AN_EVENT_ID,
body = "body.ogg",
duration = ofMinutes(1),
duration = 1.minutes,
mediaSource = MediaSource(url = "url", json = null),
mimeType = MimeTypes.Ogg,
waveform = listOf(1f, 2f).toImmutableList()
waveform = persistentListOf(1f, 2f)
)
assertThat(result).isEqualTo(expected)
}
@ -315,7 +317,7 @@ class TimelineItemContentMessageFactoryTest {
body = "body",
duration = Duration.ZERO,
mediaSource = MediaSource(url = "url", json = null),
mimeType = "application/octet-stream",
mimeType = MimeTypes.OctetStream,
formattedFileSize = "0 Bytes",
fileExtension = ""
)
@ -336,7 +338,7 @@ class TimelineItemContentMessageFactoryTest {
thumbnailSource = null,
formattedFileSize = "0 Bytes",
fileExtension = "",
mimeType = "application/octet-stream",
mimeType = MimeTypes.OctetStream,
blurhash = null,
width = null,
height = null,
@ -401,7 +403,7 @@ class TimelineItemContentMessageFactoryTest {
thumbnailSource = null,
formattedFileSize = "0 Bytes",
fileExtension = "",
mimeType = "application/octet-stream"
mimeType = MimeTypes.OctetStream
)
assertThat(result).isEqualTo(expected)
}

View file

@ -38,6 +38,11 @@ import io.element.android.libraries.matrix.test.A_USER_ID_7
import io.element.android.libraries.matrix.test.A_USER_ID_8
import io.element.android.libraries.matrix.test.A_USER_ID_9
import io.element.android.libraries.matrix.test.FakeMatrixClient
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -55,7 +60,7 @@ internal class TimelineItemContentPollFactoryTest {
@Test
fun `Disclosed poll - not ended, some votes, including one from current user`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
Truth.assertThat(
factory.create(aPollContent(votes = votes), eventId = null)
)
@ -87,7 +92,7 @@ internal class TimelineItemContentPollFactoryTest {
@Test
fun `Disclosed poll - ended, some votes, including one from current user (winner)`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
Truth.assertThat(
factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null)
)
@ -106,7 +111,7 @@ internal class TimelineItemContentPollFactoryTest {
@Test
fun `Disclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest {
val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }
val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
Truth.assertThat(
factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null)
)
@ -136,7 +141,7 @@ internal class TimelineItemContentPollFactoryTest {
@Test
fun `Undisclosed poll - not ended, some votes, including one from current user`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
Truth.assertThat(
factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes), eventId = null)
)
@ -172,7 +177,7 @@ internal class TimelineItemContentPollFactoryTest {
@Test
fun `Undisclosed poll - ended, some votes, including one from current user (winner)`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
Truth.assertThat(
factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes, endTime = 1UL), eventId = null)
)
@ -192,7 +197,7 @@ internal class TimelineItemContentPollFactoryTest {
@Test
fun `Undisclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest {
val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }
val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
Truth.assertThat(
factory.create(aPollContent(PollKind.Undisclosed).copy(votes = votes, endTime = 1UL), eventId = null)
)
@ -221,13 +226,13 @@ internal class TimelineItemContentPollFactoryTest {
private fun aPollContent(
pollKind: PollKind = PollKind.Disclosed,
votes: Map<String, List<UserId>> = emptyMap(),
votes: ImmutableMap<String, ImmutableList<UserId>> = persistentMapOf(),
endTime: ULong? = null,
): PollContent = PollContent(
question = A_POLL_QUESTION,
kind = pollKind,
maxSelections = 1UL,
answers = listOf(A_POLL_ANSWER_1, A_POLL_ANSWER_2, A_POLL_ANSWER_3, A_POLL_ANSWER_4),
answers = persistentListOf(A_POLL_ANSWER_1, A_POLL_ANSWER_2, A_POLL_ANSWER_3, A_POLL_ANSWER_4),
votes = votes,
endTime = endTime,
isEdited = false,
@ -277,17 +282,17 @@ internal class TimelineItemContentPollFactoryTest {
private val A_POLL_ANSWER_3 = PollAnswer("id_3", "French Fries")
private val A_POLL_ANSWER_4 = PollAnswer("id_4", "Hamburger")
private val MY_USER_WINNING_VOTES = mapOf(
A_POLL_ANSWER_1 to listOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4),
A_POLL_ANSWER_2 to listOf(A_USER_ID /* my vote */, A_USER_ID_5, A_USER_ID_6, A_USER_ID_7, A_USER_ID_8, A_USER_ID_9), // winner
A_POLL_ANSWER_3 to emptyList(),
A_POLL_ANSWER_4 to listOf(A_USER_ID_10),
private val MY_USER_WINNING_VOTES = persistentMapOf(
A_POLL_ANSWER_1 to persistentListOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4),
A_POLL_ANSWER_2 to persistentListOf(A_USER_ID /* my vote */, A_USER_ID_5, A_USER_ID_6, A_USER_ID_7, A_USER_ID_8, A_USER_ID_9), // winner
A_POLL_ANSWER_3 to persistentListOf(),
A_POLL_ANSWER_4 to persistentListOf(A_USER_ID_10),
)
private val OTHER_WINNING_VOTES = mapOf(
A_POLL_ANSWER_1 to listOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4, A_USER_ID_5), // winner
A_POLL_ANSWER_2 to listOf(A_USER_ID /* my vote */, A_USER_ID_6),
A_POLL_ANSWER_3 to emptyList(),
A_POLL_ANSWER_4 to listOf(A_USER_ID_7, A_USER_ID_8, A_USER_ID_9, A_USER_ID_10), // winner
private val OTHER_WINNING_VOTES = persistentMapOf(
A_POLL_ANSWER_1 to persistentListOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4, A_USER_ID_5), // winner
A_POLL_ANSWER_2 to persistentListOf(A_USER_ID /* my vote */, A_USER_ID_6),
A_POLL_ANSWER_3 to persistentListOf(),
A_POLL_ANSWER_4 to persistentListOf(A_USER_ID_7, A_USER_ID_8, A_USER_ID_9, A_USER_ID_10), // winner
)
}
}

View file

@ -86,11 +86,12 @@ class TimelineItemGrouperTest {
assertThat(result).isEqualTo(
listOf(
TimelineItem.GroupedEvents(
computeGroupIdWith(aGroupableItem),
id = computeGroupIdWith(aGroupableItem),
events = listOf(
aGroupableItem.copy("0"),
aGroupableItem.copy(id = "1"),
).toImmutableList()
).toImmutableList(),
aggregatedReadReceipts = emptyList<ReadReceiptData>().toImmutableList(),
),
)
)
@ -132,20 +133,22 @@ class TimelineItemGrouperTest {
assertThat(result).isEqualTo(
listOf(
TimelineItem.GroupedEvents(
computeGroupIdWith(aGroupableItem),
id = computeGroupIdWith(aGroupableItem),
events = listOf(
aGroupableItem,
aGroupableItem,
).toImmutableList()
).toImmutableList(),
aggregatedReadReceipts = emptyList<ReadReceiptData>().toImmutableList(),
),
aNonGroupableItem,
TimelineItem.GroupedEvents(
computeGroupIdWith(aGroupableItem),
id = computeGroupIdWith(aGroupableItem),
events = listOf(
aGroupableItem,
aGroupableItem,
aGroupableItem,
).toImmutableList()
).toImmutableList(),
aggregatedReadReceipts = emptyList<ReadReceiptData>().toImmutableList(),
)
)
)

View file

@ -0,0 +1,321 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.model
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
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.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
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.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.libraries.matrix.test.room.aMessageContent
import io.element.android.libraries.matrix.test.room.aPollContent
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class InReplyToMetadataKtTest {
@Test
fun `any message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(eventContent = aMessageContent()).metadata()
}.test {
awaitItem().let {
Truth.assertThat(it).isEqualTo(InReplyToMetadata.Text("textContent"))
}
}
}
@Test
fun `an image message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = aMessageContent(
messageType = ImageMessageType(
body = "body",
source = aMediaSource(),
info = null,
)
)
).metadata()
}.test {
awaitItem().let {
Truth.assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = aMediaSource(),
textContent = "body",
type = AttachmentThumbnailType.Image,
blurHash = null,
)
)
)
}
}
}
@Test
fun `a video message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = aMessageContent(
messageType = VideoMessageType(
body = "body",
source = aMediaSource(),
info = VideoInfo(
duration = null,
height = null,
width = null,
mimetype = null,
size = null,
thumbnailInfo = null,
thumbnailSource = aMediaSource(),
blurhash = null
),
)
)
).metadata()
}.test {
awaitItem().let {
Truth.assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = aMediaSource(),
textContent = "body",
type = AttachmentThumbnailType.Video,
blurHash = null,
)
)
)
}
}
}
@Test
fun `a file message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = aMessageContent(
messageType = FileMessageType(
body = "body",
source = aMediaSource(),
info = FileInfo(
mimetype = null,
size = null,
thumbnailInfo = null,
thumbnailSource = aMediaSource(),
),
)
)
).metadata()
}.test {
awaitItem().let {
Truth.assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = aMediaSource(),
textContent = "body",
type = AttachmentThumbnailType.File,
blurHash = null,
)
)
)
}
}
}
@Test
fun `a audio message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = aMessageContent(
messageType = AudioMessageType(
body = "body",
source = aMediaSource(),
info = AudioInfo(
duration = null,
size = null,
mimetype = null
),
)
)
).metadata()
}.test {
awaitItem().let {
Truth.assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
textContent = "body",
type = AttachmentThumbnailType.Audio,
blurHash = null,
)
)
)
}
}
}
@Test
fun `a location message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
testEnv {
anInReplyToDetails(
eventContent = aMessageContent(
messageType = LocationMessageType(
body = "body",
geoUri = "geo:3.0,4.0;u=5.0",
description = null,
)
)
).metadata()
}
}.test {
awaitItem().let {
Truth.assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = "Shared location",
type = AttachmentThumbnailType.Location,
blurHash = null,
)
)
)
}
}
}
@Test
fun `a voice message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
testEnv {
anInReplyToDetails(
eventContent = aMessageContent(
messageType = VoiceMessageType(
body = "body",
source = aMediaSource(),
info = null,
details = null,
)
)
).metadata()
}
}.test {
awaitItem().let {
Truth.assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = "Voice message",
type = AttachmentThumbnailType.Voice,
blurHash = null,
)
)
)
}
}
}
@Test
fun `a poll content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = aPollContent()
).metadata()
}.test {
awaitItem().let {
Truth.assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = "Do you like polls?",
type = AttachmentThumbnailType.Poll,
blurHash = null,
)
)
)
}
}
}
@Test
fun `any other content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = RedactedContent
).metadata()
}.test {
awaitItem().let {
Truth.assertThat(it).isEqualTo(null)
}
}
}
}
fun anInReplyToDetails(
eventId: EventId = AN_EVENT_ID,
senderId: UserId = A_USER_ID,
senderDisplayName: String? = "senderDisplayName",
senderAvatarUrl: String? = "senderAvatarUrl",
eventContent: EventContent? = aMessageContent(),
textContent: String? = "textContent",
) = InReplyToDetails(
eventId = eventId,
senderId = senderId,
senderDisplayName = senderDisplayName,
senderAvatarUrl = senderAvatarUrl,
eventContent = eventContent,
textContent = textContent,
)
@Composable
private fun testEnv(content: @Composable () -> Any?): Any? {
var result: Any? = null
CompositionLocalProvider(
LocalConfiguration provides Configuration(),
LocalContext provides ApplicationProvider.getApplicationContext(),
) {
content().apply {
result = this
}
}
return result
}

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.voicemessages.timeline
import com.google.common.truth.Truth
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
@ -143,7 +144,7 @@ private fun createDefaultVoiceMessageMediaRepo(
url = mxcUri,
json = null
),
mimeType = "audio/ogg",
mimeType = MimeTypes.Ogg,
body = "someBody.ogg"
)

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.voicemessages.timeline
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -287,7 +288,7 @@ private fun createDefaultVoiceMessagePlayer(
url = MXC_URI,
json = null
),
mimeType = "audio/ogg",
mimeType = MimeTypes.Ogg,
body = "someBody.ogg"
)

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.voicemessages.timeline
import com.google.common.truth.Truth
import io.element.android.libraries.core.mimetype.MimeTypes
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.item.TimelineItemDebugInfo
@ -29,6 +30,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -37,7 +39,7 @@ class RedactedVoiceMessageManagerTest {
@Test
fun `redacted event - no playing related media`() = runTest {
val mediaPlayer = FakeMediaPlayer().apply {
setMedia(uri = "someUri", mediaId = AN_EVENT_ID.value, mimeType = "audio/ogg")
setMedia(uri = "someUri", mediaId = AN_EVENT_ID.value, mimeType = MimeTypes.Ogg)
play()
}
val manager = aDefaultRedactedVoiceMessageManager(mediaPlayer = mediaPlayer)
@ -54,7 +56,7 @@ class RedactedVoiceMessageManagerTest {
@Test
fun `redacted event - playing related media is paused`() = runTest {
val mediaPlayer = FakeMediaPlayer().apply {
setMedia(uri = "someUri", mediaId = AN_EVENT_ID.value, mimeType = "audio/ogg")
setMedia(uri = "someUri", mediaId = AN_EVENT_ID.value, mimeType = MimeTypes.Ogg)
play()
}
val manager = aDefaultRedactedVoiceMessageManager(mediaPlayer = mediaPlayer)
@ -87,8 +89,8 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
isOwn = false,
isRemote = false,
localSendState = null,
reactions = listOf(),
receipts = listOf(),
reactions = persistentListOf(),
receipts = persistentListOf(),
sender = A_USER_ID,
senderProfile = ProfileTimelineDetails.Unavailable,
timestamp = 9442,

View file

@ -29,6 +29,7 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
class VoiceMessagePresenterTest {
@Test
@ -49,7 +50,7 @@ class VoiceMessagePresenterTest {
fun `pressing play downloads and plays`() = runTest {
val presenter = createVoiceMessagePresenter(
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
content = aTimelineItemVoiceContent(durationMs = 2_000),
content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -87,7 +88,7 @@ class VoiceMessagePresenterTest {
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { shouldFail = true },
analyticsService = analyticsService,
content = aTimelineItemVoiceContent(durationMs = 2_000),
content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -123,7 +124,7 @@ class VoiceMessagePresenterTest {
fun `pressing pause while playing pauses`() = runTest {
val presenter = createVoiceMessagePresenter(
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
content = aTimelineItemVoiceContent(durationMs = 2_000),
content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -172,7 +173,7 @@ class VoiceMessagePresenterTest {
fun `seeking before play`() = runTest {
val presenter = createVoiceMessagePresenter(
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
content = aTimelineItemVoiceContent(durationMs = 10_000),
content = aTimelineItemVoiceContent(duration = 10_000.milliseconds),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -196,7 +197,7 @@ class VoiceMessagePresenterTest {
@Test
fun `seeking after play`() = runTest {
val presenter = createVoiceMessagePresenter(
content = aTimelineItemVoiceContent(durationMs = 10_000),
content = aTimelineItemVoiceContent(duration = 10_000.milliseconds),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()