Merge branch 'develop' of https://github.com/vector-im/element-x-android into feature/dla_custom_reactions_layout

This commit is contained in:
David Langley 2023-07-18 21:20:05 +01:00
commit 7f84aef543
330 changed files with 4873 additions and 1263 deletions

View file

@ -41,6 +41,7 @@ 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
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@ -224,6 +225,20 @@ class MessagesFlowNode @AssistedInject constructor(
)
backstack.push(navTarget)
}
is TimelineItemAudioContent -> {
val mediaSource = event.content.audioSource
val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo(
name = event.content.body,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension
),
mediaSource = mediaSource,
thumbnailSource = null,
)
backstack.push(navTarget)
}
is TimelineItemLocationContent -> {
val navTarget = NavTarget.LocationViewer(
location = event.content.location,

View file

@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
@ -108,10 +109,10 @@ class MessagesPresenter @AssistedInject constructor(
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
val roomName by produceState(initialValue = room.displayName, key1 = syncUpdateFlow.value){
val roomName by produceState(initialValue = room.displayName, key1 = syncUpdateFlow.value) {
value = room.displayName
}
val roomAvatar by produceState(initialValue = room.avatarData(), key1 = syncUpdateFlow.value){
val roomAvatar by produceState(initialValue = room.avatarData(), key1 = syncUpdateFlow.value) {
value = room.avatarData()
}
var hasDismissedInviteDialog by rememberSaveable {
@ -250,28 +251,28 @@ class MessagesPresenter @AssistedInject constructor(
val textContent = messageSummaryFormatter.format(targetEvent)
val attachmentThumbnailInfo = when (targetEvent.content) {
is TimelineItemImageContent -> AttachmentThumbnailInfo(
mediaSource = targetEvent.content.mediaSource,
thumbnailSource = targetEvent.content.thumbnailSource,
textContent = targetEvent.content.body,
type = AttachmentThumbnailType.Image,
blurHash = targetEvent.content.blurhash,
)
is TimelineItemVideoContent -> AttachmentThumbnailInfo(
mediaSource = targetEvent.content.thumbnailSource,
thumbnailSource = targetEvent.content.thumbnailSource,
textContent = targetEvent.content.body,
type = AttachmentThumbnailType.Video,
blurHash = targetEvent.content.blurHash,
)
is TimelineItemFileContent -> AttachmentThumbnailInfo(
mediaSource = targetEvent.content.thumbnailSource,
thumbnailSource = targetEvent.content.thumbnailSource,
textContent = targetEvent.content.body,
type = AttachmentThumbnailType.File,
blurHash = null,
)
is TimelineItemAudioContent -> AttachmentThumbnailInfo(
textContent = targetEvent.content.body,
type = AttachmentThumbnailType.Audio,
)
is TimelineItemLocationContent -> AttachmentThumbnailInfo(
mediaSource = null,
textContent = null,
type = AttachmentThumbnailType.Location,
blurHash = null,
)
is TimelineItemTextBasedContent,
is TimelineItemRedactedContent,

View file

@ -25,7 +25,6 @@ import io.element.android.features.messages.impl.timeline.components.customreact
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
@ -48,7 +47,7 @@ fun aMessagesState() = MessagesState(
roomAvatar = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom),
userHasPermissionToSendMessage = true,
composerState = aMessageComposerState().copy(
text = StableCharSequence("Hello"),
text = "Hello",
isFullScreen = false,
mode = MessageComposerMode.Normal("Hello"),
),

View file

@ -56,6 +56,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
@ -246,8 +247,6 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
info = AttachmentThumbnailInfo(
type = AttachmentThumbnailType.Location,
textContent = stringResource(CommonStrings.common_shared_location),
mediaSource = null,
blurHash = null,
)
)
}
@ -258,9 +257,9 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
AttachmentThumbnail(
modifier = imageModifier,
info = AttachmentThumbnailInfo(
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.mediaSource,
textContent = textContent,
type = AttachmentThumbnailType.File,
type = AttachmentThumbnailType.Image,
blurHash = event.content.blurhash,
)
)
@ -272,7 +271,7 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
AttachmentThumbnail(
modifier = imageModifier,
info = AttachmentThumbnailInfo(
mediaSource = event.content.thumbnailSource,
thumbnailSource = event.content.thumbnailSource,
textContent = textContent,
type = AttachmentThumbnailType.Video,
blurHash = event.content.blurHash,
@ -286,10 +285,21 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
AttachmentThumbnail(
modifier = imageModifier,
info = AttachmentThumbnailInfo(
mediaSource = null,
thumbnailSource = event.content.thumbnailSource,
textContent = textContent,
type = AttachmentThumbnailType.File,
blurHash = null
)
)
}
content = { ContentForBody(event.content.body) }
}
is TimelineItemAudioContent -> {
icon = {
AttachmentThumbnail(
modifier = imageModifier,
info = AttachmentThumbnailInfo(
textContent = textContent,
type = AttachmentThumbnailType.Audio,
)
)
}

View file

@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Attachment
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -47,7 +48,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem
@ -59,7 +59,9 @@ import io.element.android.features.messages.impl.media.helper.formatFileExtensio
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.R
@ -103,6 +105,7 @@ fun LocalMediaView(
zoomableState = zoomableState,
modifier = modifier
)
//TODO handle audio with exoplayer
else -> MediaFileView(
localMediaViewState = localMediaViewState,
uri = localMedia?.uri,
@ -215,6 +218,7 @@ fun MediaFileView(
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) {
@ -226,12 +230,12 @@ fun MediaFileView(
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.Attachment,
imageVector = if (isAudio) Icons.Outlined.GraphicEq else Icons.Outlined.Attachment,
contentDescription = null,
tint = MaterialTheme.colorScheme.background,
modifier = Modifier
.size(32.dp)
.rotate(-45f),
.rotate(if (isAudio) 0f else -45f),
)
}
if (info != null) {

View file

@ -29,7 +29,7 @@ data class MediaInfo(
) : Parcelable
fun anImageInfo(): MediaInfo = MediaInfo(
"an image file.jpg", MimeTypes.Jpeg, "4MB","jpg"
"an image file.jpg", MimeTypes.Jpeg, "4MB", "jpg"
)
fun aVideoInfo(): MediaInfo = MediaInfo(
@ -43,3 +43,7 @@ fun aPdfInfo(): MediaInfo = MediaInfo(
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

@ -23,6 +23,7 @@ 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
@ -59,7 +60,17 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
LocalMedia(Uri.EMPTY, aFileInfo())
),
aFileInfo(),
)
),
aMediaViewerState(
Async.Loading(),
anAudioInfo(),
),
aMediaViewerState(
Async.Success(
LocalMedia(Uri.EMPTY, anAudioInfo())
),
anAudioInfo(),
),
)
}

View file

@ -26,7 +26,7 @@ sealed interface MessageComposerEvents {
data class SendMessage(val message: String) : MessageComposerEvents
object CloseSpecialMode : MessageComposerEvents
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
data class UpdateText(val text: CharSequence) : MessageComposerEvents
data class UpdateText(val text: String) : MessageComposerEvents
object AddAttachment : MessageComposerEvents
object DismissAttachmentMenu : MessageComposerEvents
sealed interface PickAttachmentSource : MessageComposerEvents {

View file

@ -34,8 +34,6 @@ 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.libraries.architecture.Presenter
import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.core.data.toStableCharSequence
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.di.RoomScope
@ -94,15 +92,15 @@ class MessageComposerPresenter @Inject constructor(
val hasFocus = remember {
mutableStateOf(false)
}
val text: MutableState<StableCharSequence> = remember {
mutableStateOf(StableCharSequence(""))
val text: MutableState<String> = rememberSaveable {
mutableStateOf("")
}
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
LaunchedEffect(messageComposerContext.composerMode) {
when (val modeValue = messageComposerContext.composerMode) {
is MessageComposerMode.Edit -> text.value = modeValue.defaultContent.toStableCharSequence()
is MessageComposerMode.Edit -> text.value = modeValue.defaultContent
else -> Unit
}
}
@ -120,9 +118,9 @@ class MessageComposerPresenter @Inject constructor(
is MessageComposerEvents.FocusChanged -> hasFocus.value = event.hasFocus
is MessageComposerEvents.UpdateText -> text.value = event.text.toStableCharSequence()
is MessageComposerEvents.UpdateText -> text.value = event.text
MessageComposerEvents.CloseSpecialMode -> {
text.value = "".toStableCharSequence()
text.value = ""
messageComposerContext.composerMode = MessageComposerMode.Normal("")
}
@ -189,11 +187,11 @@ class MessageComposerPresenter @Inject constructor(
private fun CoroutineScope.sendMessage(
text: String,
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,
textState: MutableState<StableCharSequence>
textState: MutableState<String>
) = launch {
val capturedMode = messageComposerContext.composerMode
// Reset composer right away
textState.value = "".toStableCharSequence()
textState.value = ""
updateComposerMode(MessageComposerMode.Normal(""))
when (capturedMode) {
is MessageComposerMode.Normal -> room.sendMessage(text)

View file

@ -18,13 +18,12 @@ package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.collections.immutable.ImmutableList
@Immutable
data class MessageComposerState(
val text: StableCharSequence?,
val text: String?,
val isFullScreen: Boolean,
val hasFocus: Boolean,
val mode: MessageComposerMode,
@ -32,7 +31,7 @@ data class MessageComposerState(
val attachmentsState: AttachmentsState,
val eventSink: (MessageComposerEvents) -> Unit
) {
val isSendButtonVisible: Boolean = text?.charSequence.isNullOrEmpty().not()
val isSendButtonVisible: Boolean = text.isNullOrEmpty().not()
}
@Immutable

View file

@ -17,7 +17,6 @@
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.textcomposer.MessageComposerMode
open class MessageComposerStateProvider : PreviewParameterProvider<MessageComposerState> {
@ -28,7 +27,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
}
fun aMessageComposerState() = MessageComposerState(
text = StableCharSequence(""),
text = "",
isFullScreen = false,
hasFocus = false,
mode = MessageComposerMode.Normal(content = ""),

View file

@ -47,7 +47,7 @@ fun MessageComposerView(
state.eventSink(MessageComposerEvents.CloseSpecialMode)
}
fun onComposerTextChange(text: CharSequence) {
fun onComposerTextChange(text: String) {
state.eventSink(MessageComposerEvents.UpdateText(text))
}
@ -69,7 +69,7 @@ fun MessageComposerView(
onAddAttachment = ::onAddAttachment,
onFocusChanged = ::onFocusChanged,
composerCanSendMessage = state.isSendButtonVisible,
composerText = state.text?.charSequence?.toString(),
composerText = state.text,
modifier = modifier
)
}

View file

@ -22,21 +22,24 @@ import androidx.compose.runtime.MutableState
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.ui.room.canSendEventAsState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
private const val backPaginationEventLimit = 20
@ -45,42 +48,52 @@ private const val backPaginationPageSize = 50
class TimelinePresenter @Inject constructor(
private val timelineItemsFactory: TimelineItemsFactory,
private val room: MatrixRoom,
private val dispatchers: CoroutineDispatchers,
private val appScope: CoroutineScope,
) : Presenter<TimelineState> {
private val timeline = room.timeline
@Composable
override fun present(): TimelineState {
val localCoroutineScope = rememberCoroutineScope()
val localScope = rememberCoroutineScope()
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
mutableStateOf(null)
}
var lastReadMarkerIndex by rememberSaveable { mutableStateOf(Int.MAX_VALUE) }
var lastReadMarkerId by rememberSaveable { mutableStateOf<EventId?>(null) }
val lastReadReceiptIndex = rememberSaveable { mutableStateOf(Int.MAX_VALUE) }
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
val timelineItems by timelineItemsFactory.collectItemsAsState()
val paginationState by timeline.paginationState.collectAsState()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
val hasNewItems = remember { mutableStateOf(false) }
fun handleEvents(event: TimelineEvents) {
when (event) {
TimelineEvents.LoadMore -> localCoroutineScope.paginateBackwards()
TimelineEvents.LoadMore -> localScope.paginateBackwards()
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
is TimelineEvents.OnScrollFinished -> {
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
val eventId = getLastEventIdBeforeOrAt(event.firstIndex, timelineItems) ?: return
if (event.firstIndex <= lastReadMarkerIndex && eventId != lastReadMarkerId) {
lastReadMarkerIndex = event.firstIndex
lastReadMarkerId = eventId
localCoroutineScope.sendReadReceipt(eventId)
if (event.firstIndex == 0) {
hasNewItems.value = false
}
appScope.sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex,
timelineItems = timelineItems,
lastReadReceiptIndex = lastReadReceiptIndex,
lastReadReceiptId = lastReadReceiptId
)
}
}
}
LaunchedEffect(timelineItems.size) {
computeHasNewItems(timelineItems, prevMostRecentItemId, hasNewItems)
}
LaunchedEffect(Unit) {
timeline
.timelineItems
@ -98,10 +111,49 @@ class TimelinePresenter @Inject constructor(
canReply = userHasPermissionToSendMessage,
paginationState = paginationState,
timelineItems = timelineItems,
hasNewItems = hasNewItems.value,
eventSink = ::handleEvents
)
}
/**
* 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.
*/
private suspend fun computeHasNewItems(
timelineItems: ImmutableList<TimelineItem>,
prevMostRecentItemId: MutableState<String?>,
hasNewItemsState: MutableState<Boolean>
) = withContext(dispatchers.computation) {
val newMostRecentItem = timelineItems.firstOrNull()
val prevMostRecentItemIdValue = prevMostRecentItemId.value
val newMostRecentItemId = newMostRecentItem?.identifier()
val hasNewItems = prevMostRecentItemIdValue != null &&
newMostRecentItem is TimelineItem.Event &&
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
newMostRecentItemId != prevMostRecentItemIdValue
if (hasNewItems) {
hasNewItemsState.value = true
}
prevMostRecentItemId.value = newMostRecentItemId
}
private fun CoroutineScope.sendReadReceiptIfNeeded(
firstVisibleIndex: Int,
timelineItems: ImmutableList<TimelineItem>,
lastReadReceiptIndex: MutableState<Int>,
lastReadReceiptId: MutableState<EventId?>,
) = launch(dispatchers.computation) {
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)
if (eventId != null && firstVisibleIndex <= lastReadReceiptIndex.value && eventId != lastReadReceiptId.value) {
lastReadReceiptIndex.value = firstVisibleIndex
lastReadReceiptId.value = eventId
timeline.sendReadReceipt(eventId)
}
}
private fun getLastEventIdBeforeOrAt(index: Int, items: ImmutableList<TimelineItem>): EventId? {
for (item in items.subList(index, items.count())) {
if (item is TimelineItem.Event) {
@ -114,8 +166,4 @@ class TimelinePresenter @Inject constructor(
private fun CoroutineScope.paginateBackwards() = launch {
timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize)
}
private fun CoroutineScope.sendReadReceipt(eventId: EventId) = launch {
timeline.sendReadReceipt(eventId)
}
}

View file

@ -28,5 +28,6 @@ data class TimelineState(
val highlightedEventId: EventId?,
val canReply: Boolean,
val paginationState: MatrixTimeline.PaginationState,
val hasNewItems: Boolean,
val eventSink: (TimelineEvents) -> Unit
)

View file

@ -31,8 +31,8 @@ import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -45,7 +45,8 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true),
highlightedEventId = null,
canReply = true,
eventSink = {}
hasNewItems = false,
eventSink = {},
)
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
@ -127,6 +128,7 @@ internal fun aTimelineItemEvent(
localSendState = sendState,
inReplyTo = inReplyTo,
debugInfo = debugInfo,
origin = null
)
}
@ -153,13 +155,14 @@ internal fun aTimelineItemDebugInfo(
model, originalJson, latestEditedJson
)
fun aGroupedEvents(): TimelineItem.GroupedEvents {
fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents {
val event = aTimelineItemEvent(
isMine = true,
content = aTimelineItemStateEventContent(),
groupPosition = TimelineItemGroupPosition.None
)
return TimelineItem.GroupedEvents(
id = id.toString(),
events = listOf(
event,
event,

View file

@ -21,6 +21,7 @@ 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
@ -48,10 +49,8 @@ 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.shadow
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R
@ -64,14 +63,13 @@ 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.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch
@Composable
@ -100,13 +98,6 @@ fun TimelineView(
// TODO implement this logic once we have support to 'jump to event X' in sliding sync
}
// Send an event to the presenter when the scrolling is finished, with the first visible index at the bottom.
val firstVisibleIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
LaunchedEffect(firstVisibleIndex, isScrollFinished) {
if (!isScrollFinished) return@LaunchedEffect
state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex))
}
Box(modifier = modifier) {
LazyColumn(
@ -147,8 +138,8 @@ fun TimelineView(
TimelineScrollHelper(
lazyListState = lazyListState,
timelineItems = state.timelineItems,
onScrollFinishedAt = ::onScrollFinishedAt,
hasNewItems = state.hasNewItems,
onScrollFinishedAt = ::onScrollFinishedAt
)
}
}
@ -244,63 +235,66 @@ fun TimelineItemRow(
}
@Composable
internal fun BoxScope.TimelineScrollHelper(
private fun BoxScope.TimelineScrollHelper(
lazyListState: LazyListState,
timelineItems: ImmutableList<TimelineItem>,
onScrollFinishedAt: (Int) -> Unit = {},
hasNewItems: Boolean,
onScrollFinishedAt: (Int) -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
val shouldAutoScrollToBottom by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 2 } }
val showScrollToBottomButton by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 0 } }
val canAutoScroll by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 3 } }
LaunchedEffect(timelineItems, firstVisibleItemIndex) {
if (!isScrollFinished) return@LaunchedEffect
// Auto-scroll when new timeline items appear
if (shouldAutoScrollToBottom) {
LaunchedEffect(canAutoScroll, hasNewItems) {
val shouldAutoScroll = isScrollFinished && canAutoScroll && hasNewItems
if (shouldAutoScroll) {
coroutineScope.launch {
lazyListState.animateScrollToItem(0)
}
}
}
LaunchedEffect(isScrollFinished) {
if (!isScrollFinished) return@LaunchedEffect
// Notify the parent composable about the first visible item index when scrolling finishes
onScrollFinishedAt(firstVisibleItemIndex)
LaunchedEffect(isScrollFinished) {
if (isScrollFinished) {
// Notify the parent composable about the first visible item index when scrolling finishes
onScrollFinishedAt(lazyListState.firstVisibleItemIndex)
}
}
// Jump to bottom button (display also in previews)
AnimatedVisibility(
JumpToBottomButton(
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
isVisible = !canAutoScroll,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 12.dp),
visible = showScrollToBottomButton || LocalInspectionMode.current,
enter = scaleIn(),
exit = scaleOut(),
onClick = {
coroutineScope.launch {
if (lazyListState.firstVisibleItemIndex > 10) {
lazyListState.scrollToItem(0)
} else {
lazyListState.animateScrollToItem(0)
}
}
}
)
}
@Composable
private fun JumpToBottomButton(
isVisible: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
modifier = modifier,
visible = isVisible || LocalInspectionMode.current,
enter = scaleIn(animationSpec = tween(100)),
exit = scaleOut(animationSpec = tween(100)),
) {
FloatingActionButton(
onClick = {
coroutineScope.launch {
if (firstVisibleItemIndex > 10) {
lazyListState.scrollToItem(0)
} else {
lazyListState.animateScrollToItem(0)
}
}
},
onClick = onClick,
elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp),
shape = CircleShape,
modifier = Modifier
.shadow(
elevation = 4.dp,
shape = CircleShape,
ambientColor = ElementTheme.materialColors.primary,
spotColor = ElementTheme.materialColors.primary,
)
.size(36.dp),
modifier = Modifier.size(36.dp),
containerColor = ElementTheme.colors.bgSubtleSecondary,
contentColor = ElementTheme.colors.iconSecondary
) {
@ -313,20 +307,11 @@ internal fun BoxScope.TimelineScrollHelper(
}
}
@Preview
@DayNightPreviews
@Composable
fun TimelineViewLightPreview(
fun TimelineViewPreview(
@PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent
) = ElementPreviewLight { ContentToPreview(content) }
@Preview
@Composable
fun TimelineViewDarkPreview(
@PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent
) = ElementPreviewDark { ContentToPreview(content) }
@Composable
private fun ContentToPreview(content: TimelineItemEventContent) {
) = ElementPreview {
val timelineItems = aTimelineItemList(content)
TimelineView(
state = aTimelineState(timelineItems),

View file

@ -51,8 +51,8 @@ import io.element.android.libraries.theme.ElementTheme
private val BUBBLE_RADIUS = 12.dp
private val BUBBLE_INCOMING_OFFSET = 16.dp
// Design says: The maximum width of a bubble is still 3/4 of the screen width
private const val BUBBLE_WIDTH_RATIO = 0.75f
// Design says: The maximum width of a bubble is still 3/4 of the screen width. But try with 85% now.
private const val BUBBLE_WIDTH_RATIO = 0.85f
@OptIn(ExperimentalFoundationApi::class)
@Composable

View file

@ -56,7 +56,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.constraintlayout.compose.ConstrainScope
import androidx.constraintlayout.compose.ConstraintLayout
@ -85,6 +84,7 @@ import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.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.InReplyTo
@ -521,28 +521,29 @@ private fun ReplyToContent(
private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready) =
when (val type = inReplyTo.content.type) {
is ImageMessageType -> AttachmentThumbnailInfo(
mediaSource = type.info?.thumbnailSource,
thumbnailSource = type.info?.thumbnailSource,
textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.Image,
blurHash = type.info?.blurhash,
)
is VideoMessageType -> AttachmentThumbnailInfo(
mediaSource = type.info?.thumbnailSource,
thumbnailSource = type.info?.thumbnailSource,
textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.Video,
blurHash = type.info?.blurhash,
)
is FileMessageType -> AttachmentThumbnailInfo(
mediaSource = type.info?.thumbnailSource,
thumbnailSource = type.info?.thumbnailSource,
textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.File,
blurHash = null,
)
is LocationMessageType -> AttachmentThumbnailInfo(
mediaSource = null,
textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.Location,
blurHash = null,
)
is AudioMessageType -> AttachmentThumbnailInfo(
textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.Audio,
)
else -> null
}

View file

@ -18,10 +18,11 @@ package io.element.android.features.messages.impl.timeline.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineEncryptedHistoryBannerView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
@Composable
@ -32,5 +33,7 @@ fun TimelineItemVirtualRow(
when (virtual.model) {
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
TimelineItemReadMarkerModel -> return
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier)
}
}

View file

@ -0,0 +1,97 @@
/*
* 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.event
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
@Composable
fun TimelineItemAudioView(
content: TimelineItemAudioContent,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(ElementTheme.materialColors.background),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.GraphicEq,
contentDescription = null,
tint = ElementTheme.materialColors.primary,
modifier = Modifier
.size(16.dp),
)
}
Spacer(Modifier.width(8.dp))
Column {
Text(
text = content.body,
color = ElementTheme.materialColors.primary,
maxLines = 2,
style = ElementTheme.typography.fontBodyLgRegular,
overflow = TextOverflow.Ellipsis
)
Text(
text = content.fileExtensionAndSize + extraPadding.getStr(12.sp),
color = ElementTheme.materialColors.secondary,
style = ElementTheme.typography.fontBodySmRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@DayNightPreviews
@Composable
internal fun TimelineItemAudioViewPreview(@PreviewParameter(TimelineItemAudioContentProvider::class) content: TimelineItemAudioContent) =
ElementPreview {
TimelineItemAudioView(
content,
extraPadding = noExtraPadding,
)
}

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@ -80,6 +81,11 @@ fun TimelineItemEventContentView(
extraPadding = extraPadding,
modifier = modifier
)
is TimelineItemAudioContent -> TimelineItemAudioView(
content = content,
extraPadding = extraPadding,
modifier = modifier
)
is TimelineItemStateContent -> TimelineItemStateView(
content = content,
modifier = modifier

View file

@ -22,14 +22,13 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.location.api.StaticMapView
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
@ -57,17 +56,10 @@ fun TimelineItemLocationView(
}
}
@Preview
@DayNightPreviews
@Composable
internal fun TimelineItemLocationViewLightPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) =
ElementPreviewLight { ContentToPreview(content) }
internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) =
ElementPreview {
TimelineItemLocationView(content)
}
@Preview
@Composable
internal fun TimelineItemLocationViewDarkPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) =
ElementPreviewDark { ContentToPreview(content) }
@Composable
private fun ContentToPreview(content: TimelineItemLocationContent) {
TimelineItemLocationView(content)
}

View file

@ -0,0 +1,69 @@
/*
* 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.virtual
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.theme.ElementTheme
@Composable
fun TimelineEncryptedHistoryBannerView(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 32.dp)
.clip(MaterialTheme.shapes.small)
.border(1.dp, ElementTheme.colors.borderInfoSubtle, MaterialTheme.shapes.small)
.background(ElementTheme.colors.bgInfoSubtle)
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = "Info",
tint = ElementTheme.colors.iconInfoPrimary
)
Text(
text = stringResource(R.string.screen_room_encrypted_history_banner),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textInfoPrimary
)
}
}
@DayNightPreviews
@Composable
internal fun TimelineEncryptedHistoryBannerViewPreview() {
ElementTheme {
TimelineEncryptedHistoryBannerView()
}
}

View file

@ -45,7 +45,6 @@ class TimelineItemsFactory @Inject constructor(
private val virtualItemFactory: TimelineItemVirtualFactory,
private val timelineItemGrouper: TimelineItemGrouper,
) {
private val timelineItems = MutableStateFlow(persistentListOf<TimelineItem>())
private val timelineItemsCache = arrayListOf<TimelineItem?>()

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.location.api.Location
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@ -30,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.util.FileExtensionExtr
import io.element.android.features.messages.impl.timeline.util.toHtmlDocument
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
@ -99,6 +101,14 @@ class TimelineItemContentMessageFactory @Inject constructor(
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
)
}
is AudioMessageType -> TimelineItemAudioContent(
body = messageType.body,
audioSource = messageType.source,
duration = messageType.info?.duration?.toMillis() ?: 0L,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
)
is FileMessageType -> TimelineItemFileContent(
body = messageType.body,
thumbnailSource = messageType.info?.thumbnailSource,

View file

@ -85,6 +85,7 @@ class TimelineItemEventFactory @Inject constructor(
localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo(),
debugInfo = currentTimelineItem.event.debugInfo,
origin = currentTimelineItem.event.origin,
)
}

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline.factories.virtual
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
@ -30,8 +31,13 @@ class TimelineItemVirtualFactory @Inject constructor(
fun create(
virtualTimelineItem: MatrixTimelineItem.Virtual,
): TimelineItem.Virtual {
val id = if (virtualTimelineItem.virtual is VirtualTimelineItem.EncryptedHistoryBanner) {
"encrypted_history_banner"
} else {
virtualTimelineItem.uniqueId.toString()
}
return TimelineItem.Virtual(
id = virtualTimelineItem.uniqueId.toString(),
id = id,
model = virtualTimelineItem.computeModel()
)
}
@ -40,6 +46,7 @@ class TimelineItemVirtualFactory @Inject constructor(
return when (val inner = virtual) {
is VirtualTimelineItem.DayDivider -> daySeparatorFactory.create(inner)
is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel
is VirtualTimelineItem.EncryptedHistoryBanner -> TimelineItemEncryptedHistoryBannerVirtualModel
}
}
}

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline.groups
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
@ -52,6 +53,7 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean {
is TimelineItemImageContent,
is TimelineItemFileContent,
is TimelineItemVideoContent,
is TimelineItemAudioContent,
is TimelineItemLocationContent,
TimelineItemRedactedContent,
TimelineItemUnknownContent -> false

View file

@ -16,11 +16,22 @@
package io.element.android.features.messages.impl.timeline.groups
import androidx.annotation.VisibleForTesting
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject
@SingleIn(RoomScope::class)
class TimelineItemGrouper @Inject constructor() {
/**
* Keys are identifier of items in a group, only one by group will be kept.
* Values are the actual groupIds.
*/
private val groupIds = HashMap<String, String>()
/**
* Create a new list of [TimelineItem] by grouping some of them into [TimelineItem.GroupedEvents].
*/
@ -34,14 +45,14 @@ class TimelineItemGrouper @Inject constructor() {
// timelineItem cannot be grouped
if (currentGroup.isNotEmpty()) {
// There is a pending group, create a TimelineItem.GroupedEvents if there is more than 1 Event in the pending group.
result.addGroup(currentGroup)
result.addGroup(groupIds, currentGroup)
currentGroup.clear()
}
result.add(timelineItem)
}
}
if (currentGroup.isNotEmpty()) {
result.addGroup(currentGroup)
result.addGroup(groupIds, currentGroup)
}
return result
}
@ -51,16 +62,36 @@ class TimelineItemGrouper @Inject constructor() {
* Will add a group if there is more than 1 item, else add the item to the list.
*/
private fun MutableList<TimelineItem>.addGroup(
group: MutableList<TimelineItem.Event>
groupIds: MutableMap<String, String>,
groupOfItems: MutableList<TimelineItem.Event>
) {
if (group.size == 1) {
if (groupOfItems.size == 1) {
// Do not create a group with just 1 item, just add the item to the result
add(group.first())
add(groupOfItems.first())
} else {
val groupId = groupIds.getOrPutGroupId(groupOfItems)
add(
TimelineItem.GroupedEvents(
events = group.toImmutableList()
id = groupId,
events = groupOfItems.toImmutableList()
)
)
}
}
private fun MutableMap<String, String>.getOrPutGroupId(timelineItems: List<TimelineItem>): String {
assert(timelineItems.isNotEmpty())
for (item in timelineItems) {
val itemIdentifier = item.identifier()
if (this.contains(itemIdentifier)) {
return this[itemIdentifier]!!
}
}
val timelineItem = timelineItems.first()
return computeGroupIdWith(timelineItem).also { groupId ->
this[timelineItem.identifier()] = groupId
}
}
@VisibleForTesting
internal fun computeGroupIdWith(timelineItem: TimelineItem): String = "${timelineItem.identifier()}_group"

View file

@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import kotlinx.collections.immutable.ImmutableList
@Immutable
@ -66,6 +67,7 @@ sealed interface TimelineItem {
val localSendState: LocalEventSendState?,
val inReplyTo: InReplyTo?,
val debugInfo: TimelineItemDebugInfo,
val origin: TimelineItemEventOrigin?,
) : TimelineItem {
val showSenderInformation = groupPosition.isNew() && !isMine
@ -81,9 +83,8 @@ sealed interface TimelineItem {
@Immutable
data class GroupedEvents(
val id: String,
val events: ImmutableList<Event>,
) : TimelineItem {
// use last id with a suffix. Last will not change in cas of new event from backpagination.
val id = "${events.last().id}_group"
}
) : TimelineItem
}

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.features.messages.impl.media.helper.formatFileExtensionAndSize
import io.element.android.libraries.matrix.api.media.MediaSource
data class TimelineItemAudioContent(
val body: String,
val duration: Long,
val audioSource: MediaSource,
val mimeType: String,
val formattedFileSize: String,
val fileExtension: String,
) : TimelineItemEventContent {
val fileExtensionAndSize = formatFileExtensionAndSize(fileExtension, formattedFileSize)
override val type: String = "TimelineItemAudioContent"
}

View file

@ -0,0 +1,39 @@
/*
* 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.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource
open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineItemAudioContent> {
override val values: Sequence<TimelineItemAudioContent>
get() = sequenceOf(
aTimelineItemAudioContent("A sound.mp3"),
aTimelineItemAudioContent("A bigger name sound.mp3"),
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit .mp3"),
)
}
fun aTimelineItemAudioContent(fileName: String = "A sound.mp3") = TimelineItemAudioContent(
body = fileName,
mimeType = MimeTypes.Pdf,
formattedFileSize = "100kB",
fileExtension = "mp3",
duration = 100,
audioSource = MediaSource(""),
)

View file

@ -26,7 +26,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
aTimelineItemEncryptedContent(),
aTimelineItemImageContent(),
aTimelineItemVideoContent(),
aTimelineItemFileContent("A file.pdf"),
aTimelineItemFileContent(),
aTimelineItemFileContent("A bigger file name which doesn't fit.pdf"),
aTimelineItemLocationContent(),
aTimelineItemLocationContent("Location description"),

View file

@ -23,7 +23,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineItemFileContent> {
override val values: Sequence<TimelineItemFileContent>
get() = sequenceOf(
aTimelineItemFileContent("A file.pdf"),
aTimelineItemFileContent(),
aTimelineItemFileContent("A bigger name file.pdf"),
aTimelineItemFileContent("An even bigger bigger bigger bigger bigger bigger bigger file name which doesn't fit .pdf"),
)
@ -31,7 +31,7 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineIt
fun aTimelineItemFileContent(fileName: String = "A file.pdf") = TimelineItemFileContent(
body = fileName,
thumbnailSource = MediaSource(url = ""),
thumbnailSource = null,
fileSource = MediaSource(url = ""),
mimeType = MimeTypes.Pdf,
formattedFileSize = "100kB",

View file

@ -31,7 +31,7 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
fun aTimelineItemVideoContent() = TimelineItemVideoContent(
body = "Video.mp4",
thumbnailSource = MediaSource(url = ""),
thumbnailSource = null,
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
aspectRatio = 0.5f,
duration = 100,

View file

@ -0,0 +1,21 @@
/*
* 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.virtual
object TimelineItemEncryptedHistoryBannerVirtualModel : TimelineItemVirtualModel {
override val type: String = "TimelineItemEncryptedHistoryBannerVirtualModel"
}

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.utils.messagesummary
import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
@ -50,6 +51,7 @@ class MessageSummaryFormatterImpl @Inject constructor(
is TimelineItemImageContent -> context.getString(CommonStrings.common_image)
is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)
is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
}
}
}

View file

@ -574,6 +574,8 @@ class MessagesPresenterTest {
val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
room = matrixRoom,
dispatchers = coroutineDispatchers,
appScope = this
)
val buildMeta = aBuildMeta()
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)

View file

@ -52,4 +52,5 @@ internal fun aMessageEvent(
localSendState = sendState,
inReplyTo = inReplyTo,
debugInfo = debugInfo,
origin = null
)

View file

@ -31,7 +31,6 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -84,7 +83,7 @@ class MessageComposerPresenterTest {
}.test {
val initialState = awaitItem()
assertThat(initialState.isFullScreen).isFalse()
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
assertThat(initialState.text).isEqualTo("")
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
assertThat(initialState.showAttachmentSourcePicker).isFalse()
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
@ -117,11 +116,11 @@ class MessageComposerPresenterTest {
val initialState = awaitItem()
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
val withMessageState = awaitItem()
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(""))
val withEmptyMessageState = awaitItem()
assertThat(withEmptyMessageState.text).isEqualTo(StableCharSequence(""))
assertThat(withEmptyMessageState.text).isEqualTo("")
assertThat(withEmptyMessageState.isSendButtonVisible).isFalse()
}
}
@ -138,7 +137,7 @@ class MessageComposerPresenterTest {
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
state = awaitItem()
assertThat(state.text).isEqualTo(StableCharSequence(A_MESSAGE))
assertThat(state.text).isEqualTo(A_MESSAGE)
assertThat(state.isSendButtonVisible).isTrue()
backToNormalMode(state, skipCount = 1)
}
@ -155,7 +154,7 @@ class MessageComposerPresenterTest {
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
assertThat(state.text).isEqualTo(StableCharSequence(""))
assertThat(state.text).isEqualTo("")
assertThat(state.isSendButtonVisible).isFalse()
backToNormalMode(state)
}
@ -172,7 +171,7 @@ class MessageComposerPresenterTest {
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
assertThat(state.text).isEqualTo(StableCharSequence(""))
assertThat(state.text).isEqualTo("")
assertThat(state.isSendButtonVisible).isFalse()
backToNormalMode(state)
}
@ -187,11 +186,11 @@ class MessageComposerPresenterTest {
val initialState = awaitItem()
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
val withMessageState = awaitItem()
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE))
val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
assertThat(messageSentState.text).isEqualTo("")
assertThat(messageSentState.isSendButtonVisible).isFalse()
}
}
@ -207,21 +206,21 @@ class MessageComposerPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
assertThat(initialState.text).isEqualTo("")
val mode = anEditMode()
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
skipItems(1)
val withMessageState = awaitItem()
assertThat(withMessageState.mode).isEqualTo(mode)
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
val withEditedMessageState = awaitItem()
assertThat(withEditedMessageState.text).isEqualTo(StableCharSequence(ANOTHER_MESSAGE))
assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE)
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
skipItems(1)
val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
assertThat(messageSentState.text).isEqualTo("")
assertThat(messageSentState.isSendButtonVisible).isFalse()
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
}
@ -238,21 +237,21 @@ class MessageComposerPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
assertThat(initialState.text).isEqualTo("")
val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID)
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
skipItems(1)
val withMessageState = awaitItem()
assertThat(withMessageState.mode).isEqualTo(mode)
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
val withEditedMessageState = awaitItem()
assertThat(withEditedMessageState.text).isEqualTo(StableCharSequence(ANOTHER_MESSAGE))
assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE)
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
skipItems(1)
val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
assertThat(messageSentState.text).isEqualTo("")
assertThat(messageSentState.isSendButtonVisible).isFalse()
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
}
@ -269,21 +268,21 @@ class MessageComposerPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
assertThat(initialState.text).isEqualTo("")
val mode = aReplyMode()
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
val state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
assertThat(state.text).isEqualTo(StableCharSequence(""))
assertThat(state.text).isEqualTo("")
assertThat(state.isSendButtonVisible).isFalse()
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_REPLY))
val withMessageState = awaitItem()
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_REPLY))
assertThat(withMessageState.text).isEqualTo(A_REPLY)
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY))
skipItems(1)
val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
assertThat(messageSentState.text).isEqualTo("")
assertThat(messageSentState.isSendButtonVisible).isFalse()
assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY)
}
@ -326,7 +325,7 @@ class MessageComposerPresenterTest {
Result.success(
MediaUploadInfo.Image(
file = File("/some/path"),
info = ImageInfo(
imageInfo = ImageInfo(
width = null,
height = null,
mimetype = null,
@ -359,7 +358,7 @@ class MessageComposerPresenterTest {
Result.success(
MediaUploadInfo.Video(
file = File("/some/path"),
info = VideoInfo(
videoInfo = VideoInfo(
width = null,
height = null,
mimetype = null,
@ -486,7 +485,7 @@ class MessageComposerPresenterTest {
skipItems(skipCount)
val normalState = awaitItem()
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
assertThat(normalState.text).isEqualTo(StableCharSequence(""))
assertThat(normalState.text).isEqualTo("")
assertThat(normalState.isSendButtonVisible).isFalse()
}

View file

@ -23,22 +23,25 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
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.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aMessageContent
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.tests.testutils.awaitWithLatch
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class TimelinePresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
room = FakeMatrixRoom(),
)
val presenter = createTimelinePresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -51,10 +54,7 @@ class TimelinePresenterTest {
@Test
fun `present - load more`() = runTest {
val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
room = FakeMatrixRoom(),
)
val presenter = createTimelinePresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -73,10 +73,7 @@ class TimelinePresenterTest {
@Test
fun `present - set highlighted event`() = runTest {
val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
room = FakeMatrixRoom(),
)
val presenter = createTimelinePresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -94,70 +91,112 @@ class TimelinePresenterTest {
@Test
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
val timeline = FakeMatrixTimeline()
val timelineItemsFactory = aTimelineItemsFactory().apply {
replaceWith(listOf(MatrixTimelineItem.Event(0, anEventTimelineItem())))
}
val room = FakeMatrixRoom(matrixTimeline = timeline)
val presenter = TimelinePresenter(
timelineItemsFactory = timelineItemsFactory,
room = room,
val timeline = FakeMatrixTimeline(
initialTimelineItems = listOf(
MatrixTimelineItem.Event(0, anEventTimelineItem())
)
)
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
val initialState = awaitItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
// Wait for timeline items to be populated
skipItems(1)
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
}
assertThat(timeline.sendReadReceiptCount).isEqualTo(1)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - on scroll finished will not send read receipt no event is before the index`() = runTest {
val timeline = FakeMatrixTimeline()
val timelineItemsFactory = aTimelineItemsFactory().apply {
replaceWith(listOf(MatrixTimelineItem.Event(0, anEventTimelineItem())))
}
val room = FakeMatrixRoom(matrixTimeline = timeline)
val presenter = TimelinePresenter(
timelineItemsFactory = timelineItemsFactory,
room = room,
val timeline = FakeMatrixTimeline(
initialTimelineItems = listOf(
MatrixTimelineItem.Event(0, anEventTimelineItem())
)
)
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
val initialState = awaitItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
// Wait for timeline items to be populated
skipItems(1)
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
}
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest {
val timeline = FakeMatrixTimeline()
val timelineItemsFactory = aTimelineItemsFactory().apply {
replaceWith(listOf(MatrixTimelineItem.Virtual(0, VirtualTimelineItem.ReadMarker)))
}
val room = FakeMatrixRoom(matrixTimeline = timeline)
val presenter = TimelinePresenter(
timelineItemsFactory = timelineItemsFactory,
room = room,
val timeline = FakeMatrixTimeline(
initialTimelineItems = listOf(
MatrixTimelineItem.Virtual(0, VirtualTimelineItem.ReadMarker)
)
)
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
val initialState = awaitItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
// Wait for timeline items to be populated
skipItems(1)
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
}
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - covers hasNewItems scenarios`() = runTest {
val timeline = FakeMatrixTimeline()
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.hasNewItems).isFalse()
assertThat(initialState.timelineItems.size).isEqualTo(0)
timeline.updateTimelineItems {
listOf(MatrixTimelineItem.Event(0, anEventTimelineItem(content = aMessageContent())))
}
skipItems(1)
assertThat(awaitItem().timelineItems.size).isEqualTo(1)
timeline.updateTimelineItems { items ->
items + listOf(MatrixTimelineItem.Event(1, anEventTimelineItem(content = aMessageContent())))
}
skipItems(1)
assertThat(awaitItem().timelineItems.size).isEqualTo(2)
assertThat(awaitItem().hasNewItems).isTrue()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
assertThat(awaitItem().hasNewItems).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
private fun TestScope.createTimelinePresenter(
timeline: MatrixTimeline = FakeMatrixTimeline(),
timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory()
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactory = timelineItemsFactory,
room = FakeMatrixRoom(matrixTimeline = timeline),
dispatchers = testCoroutineDispatchers(),
appScope = this
)
}
}

View file

@ -20,13 +20,13 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.impl.timeline.groups.computeGroupIdWith
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo
import kotlinx.collections.immutable.toImmutableList
@ -36,7 +36,7 @@ class TimelineItemGrouperTest {
private val sut = TimelineItemGrouper()
private val aGroupableItem = TimelineItem.Event(
id = AN_EVENT_ID.value,
id = "0",
senderId = A_USER_ID,
senderAvatar = anAvatarData(),
senderDisplayName = "",
@ -45,6 +45,7 @@ class TimelineItemGrouperTest {
localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
inReplyTo = null,
debugInfo = aTimelineItemDebugInfo(),
origin = null
)
private val aNonGroupableItem = aMessageEvent()
private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today"))
@ -75,16 +76,17 @@ class TimelineItemGrouperTest {
fun `test groupables and ensure reordering`() {
val result = sut.group(
listOf(
aGroupableItem.copy(id = AN_EVENT_ID_2.value),
aGroupableItem,
aGroupableItem.copy(id = "1"),
aGroupableItem.copy(id = "0"),
),
)
assertThat(result).isEqualTo(
listOf(
TimelineItem.GroupedEvents(
computeGroupIdWith(aGroupableItem),
events = listOf(
aGroupableItem,
aGroupableItem.copy(id = AN_EVENT_ID_2.value),
aGroupableItem.copy("0"),
aGroupableItem.copy(id = "1"),
).toImmutableList()
),
)
@ -127,6 +129,7 @@ class TimelineItemGrouperTest {
assertThat(result).isEqualTo(
listOf(
TimelineItem.GroupedEvents(
computeGroupIdWith(aGroupableItem),
events = listOf(
aGroupableItem,
aGroupableItem,
@ -134,6 +137,7 @@ class TimelineItemGrouperTest {
),
aNonGroupableItem,
TimelineItem.GroupedEvents(
computeGroupIdWith(aGroupableItem),
events = listOf(
aGroupableItem,
aGroupableItem,
@ -143,4 +147,20 @@ class TimelineItemGrouperTest {
)
)
}
@Test
fun `when calling multiple time the method group over a growing list of groupable items, then groupId is stable`() {
// When
val groupableItems = mutableListOf(
aGroupableItem.copy(id = "1"),
aGroupableItem.copy(id = "2")
)
val expectedGroupId = sut.group(groupableItems).first().identifier()
groupableItems.add(0, aGroupableItem.copy("3"))
groupableItems.add(2, aGroupableItem.copy("4"))
groupableItems.add(aGroupableItem.copy("5"))
val actualGroupId = sut.group(groupableItems).first().identifier()
// Then
assertThat(actualGroupId).isEqualTo(expectedGroupId)
}
}