Merge pull request #2759 from element-hq/feature/fga/permalink_timeline

Permalink timeline
This commit is contained in:
Benoit Marty 2024-04-30 10:58:33 +02:00 committed by GitHub
commit ae8ee8704f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
383 changed files with 3579 additions and 1506 deletions

View file

@ -32,7 +32,7 @@ class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint {
return object : MessagesEntryPoint.NodeBuilder {
override fun params(params: MessagesEntryPoint.Params): MessagesEntryPoint.NodeBuilder {
plugins += MessagesNode.Inputs(focusedEventId = params.focusedEventId)
plugins += MessagesFlowNode.Inputs(focusedEventId = params.focusedEventId)
return this
}

View file

@ -54,6 +54,7 @@ import io.element.android.libraries.architecture.BackstackWithOverlayBox
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.architecture.overlay.Overlay
import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.di.ApplicationContext
@ -81,7 +82,7 @@ class MessagesFlowNode @AssistedInject constructor(
private val createPollEntryPoint: CreatePollEntryPoint,
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Messages(plugins.filterIsInstance<Inputs>().firstOrNull()?.focusedEventId),
initialElement = NavTarget.Messages,
savedStateMap = buildContext.savedStateMap,
),
overlay = Overlay(
@ -91,15 +92,14 @@ class MessagesFlowNode @AssistedInject constructor(
plugins = plugins
) {
data class Inputs(val focusedEventId: EventId?) : NodeInputs
private val inputs = inputs<Inputs>()
sealed interface NavTarget : Parcelable {
@Parcelize
data object Empty : NavTarget
@Parcelize
data class Messages(
val focusedEventId: EventId? = null,
) : NavTarget
data object Messages : NavTarget
@Parcelize
data class MediaViewer(
@ -191,10 +191,10 @@ class MessagesFlowNode @AssistedInject constructor(
ElementCallActivity.start(context, inputs)
}
}
val params = MessagesNode.Inputs(
focusedEventId = navTarget.focusedEventId,
val inputs = MessagesNode.Inputs(
focusedEventId = inputs.focusedEventId,
)
createNode<MessagesNode>(buildContext, listOf(callback, params))
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
}
is NavTarget.MediaViewer -> {
val inputs = MediaViewerNode.Inputs(

View file

@ -19,6 +19,11 @@ package io.element.android.features.messages.impl
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.lifecycle.subscribe
@ -30,12 +35,15 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.androidutils.system.toast
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope
@ -64,13 +72,15 @@ class MessagesNode @AssistedInject constructor(
private val permalinkParser: PermalinkParser,
@ApplicationContext
private val context: Context,
private val timelineController: TimelineController,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(this)
private val callback = plugins<Callback>().firstOrNull()
// TODO Handle navigation to the Event
data class Inputs(val focusedEventId: EventId?) : NodeInputs
private val inputs = inputs<Inputs>()
interface Callback : Plugin {
fun onRoomDetailsClicked()
fun onEventClicked(event: TimelineItem.Event): Boolean
@ -86,12 +96,14 @@ class MessagesNode @AssistedInject constructor(
fun onJoinCallClicked(roomId: RoomId)
}
init {
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onCreate = {
analyticsService.capture(room.toAnalyticsViewRoom())
},
onDestroy = {
timelineController.close()
mediaPlayer.close()
}
)
@ -116,6 +128,7 @@ class MessagesNode @AssistedInject constructor(
private fun onLinkClicked(
context: Context,
url: String,
eventSink: (TimelineEvents) -> Unit,
) {
when (val permalink = permalinkParser.parse(url)) {
is PermalinkData.UserLink -> {
@ -124,7 +137,7 @@ class MessagesNode @AssistedInject constructor(
callback?.onUserDataClicked(permalink.userId)
}
is PermalinkData.RoomLink -> {
handleRoomLinkClicked(permalink)
handleRoomLinkClicked(permalink, eventSink)
}
is PermalinkData.FallbackLink,
is PermalinkData.RoomEmailInviteLink -> {
@ -133,11 +146,11 @@ class MessagesNode @AssistedInject constructor(
}
}
private fun handleRoomLinkClicked(roomLink: PermalinkData.RoomLink) {
private fun handleRoomLinkClicked(roomLink: PermalinkData.RoomLink, eventSink: (TimelineEvents) -> Unit) {
if (room.matches(roomLink.roomIdOrAlias)) {
if (roomLink.eventId != null) {
// TODO Handle navigation to the Event
context.toast("TODO Handle navigation to the Event ${roomLink.eventId}")
val eventId = roomLink.eventId
if (eventId != null) {
eventSink(TimelineEvents.FocusOnEvent(eventId))
} else {
// Click on the same room, ignore
context.toast("Already viewing this room!")
@ -189,12 +202,23 @@ class MessagesNode @AssistedInject constructor(
onEventClicked = this::onEventClicked,
onPreviewAttachments = this::onPreviewAttachments,
onUserDataClicked = this::onUserDataClicked,
onLinkClicked = { onLinkClicked(context, it) },
onLinkClicked = { onLinkClicked(context, it, state.timelineState.eventSink) },
onSendLocationClicked = this::onSendLocationClicked,
onCreatePollClicked = this::onCreatePollClicked,
onJoinCallClicked = this::onJoinCallClicked,
modifier = modifier,
)
var focusedEventId by rememberSaveable {
mutableStateOf(inputs.focusedEventId)
}
LaunchedEffect(Unit) {
focusedEventId?.also { eventId ->
state.timelineState.eventSink(TimelineEvents.FocusOnEvent(eventId))
}
// Reset the focused event id to null to avoid refocusing when restoring node.
focusedEventId = null
}
}
}
}

View file

@ -38,6 +38,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.timeline.TimelineController
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.TimelineState
@ -116,6 +117,7 @@ class MessagesPresenter @AssistedInject constructor(
private val htmlConverterProvider: HtmlConverterProvider,
@Assisted private val navigator: MessagesNavigator,
private val buildMeta: BuildMeta,
private val timelineController: TimelineController,
) : Presenter<MessagesState> {
private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator)
@ -185,10 +187,6 @@ class MessagesPresenter @AssistedInject constructor(
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
LaunchedEffect(composerState.mode.relatedEventId) {
timelineState.eventSink(TimelineEvents.SetHighlightedEvent(composerState.mode.relatedEventId))
}
val enableTextFormatting by appPreferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
var enableVoiceMessages by remember { mutableStateOf(false) }
@ -290,8 +288,10 @@ class MessagesPresenter @AssistedInject constructor(
emoji: String,
eventId: EventId,
) = launch(dispatchers.io) {
room.toggleReaction(emoji, eventId)
.onFailure { Timber.e(it) }
timelineController.invokeOnCurrentTimeline {
toggleReaction(emoji, eventId)
.onFailure { Timber.e(it) }
}
}
private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState<AsyncData<Unit>>) = launch(dispatchers.io) {

View file

@ -106,6 +106,8 @@ fun aMessagesState(
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
timelineState: TimelineState = aTimelineState(
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
// Render a focused event for an event with sender information displayed
focusedEventIndex = 2,
),
retrySendMenuState: RetrySendMenuState = aRetrySendMenuState(),
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),

View file

@ -382,20 +382,19 @@ private fun MessagesViewContent(
},
content = { paddingValues ->
TimelineView(
modifier = Modifier.padding(paddingValues),
state = state.timelineState,
roomName = state.roomName.dataOrNull(),
typingNotificationState = state.typingNotificationState,
onMessageClicked = onMessageClicked,
onMessageLongClicked = onMessageLongClicked,
onUserDataClicked = onUserDataClicked,
onLinkClicked = onLinkClicked,
onMessageClicked = onMessageClicked,
onMessageLongClicked = onMessageLongClicked,
onTimestampClicked = onTimestampClicked,
onSwipeToReply = onSwipeToReply,
onReactionClicked = onReactionClicked,
onReactionLongClicked = onReactionLongClicked,
onMoreReactionsClicked = onMoreReactionsClicked,
onReadReceiptClick = onReadReceiptClick,
onSwipeToReply = onSwipeToReply,
modifier = Modifier.padding(paddingValues),
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
)
},

View file

@ -29,7 +29,8 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.libraries.matrix.api.timeline.getActiveTimeline
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
@ -37,8 +38,8 @@ import kotlinx.coroutines.launch
class ForwardMessagesPresenter @AssistedInject constructor(
@Assisted eventId: String,
private val room: MatrixRoom,
private val matrixCoroutineScope: CoroutineScope,
private val timelineProvider: TimelineProvider,
) : Presenter<ForwardMessagesState> {
private val eventId: EventId = EventId(eventId)
@ -79,7 +80,7 @@ class ForwardMessagesPresenter @AssistedInject constructor(
isForwardMessagesState: MutableState<AsyncData<ImmutableList<RoomId>>>,
) = launch {
isForwardMessagesState.value = AsyncData.Loading()
room.forwardEvent(eventId, roomIds).fold(
timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds).fold(
{ isForwardMessagesState.value = AsyncData.Success(roomIds) },
{ isForwardMessagesState.value = AsyncData.Failure(it) }
)

View file

@ -38,6 +38,7 @@ 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.mentions.MentionSuggestion
import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@ -100,6 +101,7 @@ class MessageComposerPresenter @Inject constructor(
private val permalinkParser: PermalinkParser,
private val permalinkBuilder: PermalinkBuilder,
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val timelineController: TimelineController,
) : Presenter<MessageComposerState> {
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
private var pendingEvent: MessageComposerEvents? = null
@ -264,7 +266,9 @@ class MessageComposerPresenter @Inject constructor(
is MessageComposerMode.Quote -> null
}.let { relatedEventId ->
appCoroutineScope.launch {
room.enterSpecialMode(relatedEventId)
timelineController.invokeOnCurrentTimeline {
enterSpecialMode(relatedEventId)
}
}
}
}
@ -386,16 +390,17 @@ class MessageComposerPresenter @Inject constructor(
is MessageComposerMode.Edit -> {
val eventId = capturedMode.eventId
val transactionId = capturedMode.transactionId
room.editMessage(eventId, transactionId, message.markdown, message.html, mentions)
timelineController.invokeOnCurrentTimeline {
editMessage(eventId, transactionId, message.markdown, message.html, mentions)
}
}
is MessageComposerMode.Quote -> TODO()
is MessageComposerMode.Reply -> room.replyMessage(
capturedMode.eventId,
message.markdown,
message.html,
mentions
)
is MessageComposerMode.Reply -> {
timelineController.invokeOnCurrentTimeline {
replyMessage(capturedMode.eventId, message.markdown, message.html, mentions)
}
}
}
analyticsService.capture(
Composer(

View file

@ -0,0 +1,136 @@
/*
* Copyright (c) 2024 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
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
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.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.io.Closeable
import java.util.Optional
import javax.inject.Inject
/**
* This controller is responsible of using the right timeline to display messages and make associated actions.
* It can be focused on the live timeline or on a detached timeline (focusing an unknown event).
*/
@SingleIn(RoomScope::class)
@ContributesBinding(RoomScope::class, boundType = TimelineProvider::class)
class TimelineController @Inject constructor(
private val room: MatrixRoom,
) : Closeable, TimelineProvider {
private val coroutineScope = CoroutineScope(SupervisorJob())
private val liveTimeline = flowOf(room.liveTimeline)
private val detachedTimeline = MutableStateFlow<Optional<Timeline>>(Optional.empty())
@OptIn(ExperimentalCoroutinesApi::class)
fun timelineItems(): Flow<List<MatrixTimelineItem>> {
return currentTimelineFlow.flatMapLatest { it.timelineItems }
}
fun isLive(): Flow<Boolean> {
return detachedTimeline.map { !it.isPresent }
}
suspend fun invokeOnCurrentTimeline(block: suspend (Timeline.() -> Any)) {
currentTimelineFlow.value.run {
block(this)
}
}
suspend fun focusOnEvent(eventId: EventId): Result<Unit> {
return room.timelineFocusedOnEvent(eventId)
.onFailure {
if (it is CancellationException) {
throw it
}
}
.map { newDetachedTimeline ->
detachedTimeline.getAndUpdate { current ->
if (current.isPresent) {
current.get().close()
}
Optional.of(newDetachedTimeline)
}
}
}
/**
* Makes sure the controller is focused on the live timeline.
* This does close the detached timeline if any.
*/
fun focusOnLive() {
closeDetachedTimeline()
}
private fun closeDetachedTimeline() {
detachedTimeline.getAndUpdate {
when {
it.isPresent -> {
it.get().close()
Optional.empty()
}
else -> Optional.empty()
}
}
}
override fun close() {
coroutineScope.cancel()
closeDetachedTimeline()
}
suspend fun paginate(direction: Timeline.PaginationDirection): Result<Boolean> {
return currentTimelineFlow.value.paginate(direction)
.onSuccess { hasReachedEnd ->
if (direction == Timeline.PaginationDirection.FORWARDS && hasReachedEnd) {
focusOnLive()
}
}
}
private val currentTimelineFlow = combine(liveTimeline, detachedTimeline) { live, detached ->
when {
detached.isPresent -> detached.get()
else -> live
}
}.stateIn(coroutineScope, SharingStarted.Eagerly, room.liveTimeline)
override fun activeTimelineFlow(): StateFlow<Timeline> {
return currentTimelineFlow
}
}

View file

@ -17,17 +17,21 @@
package io.element.android.features.messages.impl.timeline
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
sealed interface TimelineEvents {
data object LoadMore : TimelineEvents
data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents
data class OnScrollFinished(val firstIndex: Int) : TimelineEvents
data class FocusOnEvent(val eventId: EventId) : TimelineEvents
data object ClearFocusRequestState : TimelineEvents
data object JumpToLive : TimelineEvents
/**
* Events coming from a timeline item.
*/
sealed interface EventFromTimelineItem : TimelineEvents
data class LoadMore(val direction: Timeline.PaginationDirection) : EventFromTimelineItem
/**
* Events coming from a poll item.
*/

View file

@ -0,0 +1,64 @@
/*
* Copyright (c) 2024 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
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 io.element.android.libraries.matrix.api.core.EventId
import timber.log.Timber
import javax.inject.Inject
@SingleIn(RoomScope::class)
class TimelineItemIndexer @Inject constructor() {
private val timelineEventsIndexes = mutableMapOf<EventId, Int>()
fun isKnown(eventId: EventId): Boolean {
return timelineEventsIndexes.containsKey(eventId).also {
Timber.d("$eventId isKnown = $it")
}
}
fun indexOf(eventId: EventId): Int {
return (timelineEventsIndexes[eventId] ?: -1).also {
Timber.d("indexOf $eventId= $it")
}
}
fun process(timelineItems: List<TimelineItem>) {
Timber.d("process ${timelineItems.size} items")
timelineEventsIndexes.clear()
timelineItems.forEachIndexed { index, timelineItem ->
when (timelineItem) {
is TimelineItem.Event -> {
processEvent(timelineItem, index)
}
is TimelineItem.GroupedEvents -> {
timelineItem.events.forEach { event ->
processEvent(event, index)
}
}
else -> Unit
}
}
}
private fun processEvent(event: TimelineItem.Event, index: Int) {
if (event.eventId == null) return
timelineEventsIndexes[event.eventId] = index
}
}

View file

@ -54,11 +54,9 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private const val BACK_PAGINATION_EVENT_LIMIT = 20
private const val BACK_PAGINATION_PAGE_SIZE = 50
class TimelinePresenter @AssistedInject constructor(
private val timelineItemsFactory: TimelineItemsFactory,
private val timelineItemIndexer: TimelineItemIndexer,
private val room: MatrixRoom,
private val dispatchers: CoroutineDispatchers,
private val appScope: CoroutineScope,
@ -67,50 +65,62 @@ class TimelinePresenter @AssistedInject constructor(
private val sendPollResponseAction: SendPollResponseAction,
private val endPollAction: EndPollAction,
private val sessionPreferencesStore: SessionPreferencesStore,
private val timelineController: TimelineController,
) : Presenter<TimelineState> {
@AssistedFactory
interface Factory {
fun create(navigator: MessagesNavigator): TimelinePresenter
}
private val timeline = room.timeline
@Composable
override fun present(): TimelineState {
val localScope = rememberCoroutineScope()
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
val focusedEventId: MutableState<EventId?> = rememberSaveable {
mutableStateOf(null)
}
val focusRequestState: MutableState<FocusRequestState> = remember {
mutableStateOf(FocusRequestState.None)
}
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.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION, updateKey = syncUpdateFlow.value)
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
val newItemState = remember { mutableStateOf(NewEventState.None) }
val newEventState = remember { mutableStateOf(NewEventState.None) }
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
val isLive by timelineController.isLive().collectAsState(initial = true)
fun handleEvents(event: TimelineEvents) {
when (event) {
TimelineEvents.LoadMore -> localScope.paginateBackwards()
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
is TimelineEvents.OnScrollFinished -> {
if (event.firstIndex == 0) {
newItemState.value = NewEventState.None
is TimelineEvents.LoadMore -> {
localScope.launch {
timelineController.paginate(direction = event.direction)
}
}
is TimelineEvents.OnScrollFinished -> {
if (isLive) {
if (event.firstIndex == 0) {
newEventState.value = NewEventState.None
}
println("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
appScope.sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex,
timelineItems = timelineItems,
lastReadReceiptId = lastReadReceiptId,
readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE,
)
} else {
newEventState.value = NewEventState.None
}
appScope.sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex,
timelineItems = timelineItems,
lastReadReceiptId = lastReadReceiptId,
readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE,
)
}
is TimelineEvents.PollAnswerSelected -> appScope.launch {
sendPollResponseAction.execute(
@ -123,28 +133,58 @@ class TimelinePresenter @AssistedInject constructor(
pollStartId = event.pollStartId,
)
}
is TimelineEvents.PollEditClicked ->
is TimelineEvents.PollEditClicked -> {
navigator.onEditPollClicked(event.pollStartId)
}
is TimelineEvents.FocusOnEvent -> localScope.launch {
focusedEventId.value = event.eventId
if (timelineItemIndexer.isKnown(event.eventId)) {
val index = timelineItemIndexer.indexOf(event.eventId)
focusRequestState.value = FocusRequestState.Cached(index)
} else {
focusRequestState.value = FocusRequestState.Fetching
timelineController.focusOnEvent(event.eventId)
.fold(
onSuccess = {
focusRequestState.value = FocusRequestState.Fetched
},
onFailure = {
focusRequestState.value = FocusRequestState.Failure(it)
}
)
}
}
is TimelineEvents.ClearFocusRequestState -> {
focusRequestState.value = FocusRequestState.None
}
is TimelineEvents.JumpToLive -> {
timelineController.focusOnLive()
}
}
}
LaunchedEffect(timelineItems.size) {
computeNewItemState(timelineItems, prevMostRecentItemId, newItemState)
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
}
LaunchedEffect(timelineItems.size, focusRequestState.value, focusedEventId.value) {
val currentFocusedEventId = focusedEventId.value
if (focusRequestState.value is FocusRequestState.Fetched && currentFocusedEventId != null) {
if (timelineItemIndexer.isKnown(currentFocusedEventId)) {
val index = timelineItemIndexer.indexOf(currentFocusedEventId)
focusRequestState.value = FocusRequestState.Cached(index)
}
}
}
LaunchedEffect(Unit) {
combine(timeline.timelineItems, room.membersStateFlow) { items, membersState ->
combine(timelineController.timelineItems(), room.membersStateFlow) { items, membersState ->
timelineItemsFactory.replaceWith(
timelineItems = items,
roomMembers = membersState.roomMembers().orEmpty()
)
items
}
.onEach { timelineItems ->
if (timelineItems.isEmpty()) {
paginateBackwards()
}
}
.onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem)
.launchIn(this)
}
@ -152,6 +192,7 @@ class TimelinePresenter @AssistedInject constructor(
val timelineRoomInfo by remember {
derivedStateOf {
TimelineRoomInfo(
name = room.displayName,
isDm = room.isDm,
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
@ -160,11 +201,12 @@ class TimelinePresenter @AssistedInject constructor(
}
return TimelineState(
timelineRoomInfo = timelineRoomInfo,
highlightedEventId = highlightedEventId.value,
paginationState = paginationState,
timelineItems = timelineItems,
renderReadReceipts = renderReadReceipts,
newEventState = newItemState.value,
newEventState = newEventState.value,
isLive = isLive,
focusedEventId = focusedEventId.value,
focusRequestState = focusRequestState.value,
eventSink = { handleEvents(it) }
)
}
@ -190,6 +232,7 @@ class TimelinePresenter @AssistedInject constructor(
newMostRecentItem is TimelineItem.Event &&
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
newMostRecentItemId != prevMostRecentItemIdValue
if (hasNewEvent) {
val newMostRecentEvent = newMostRecentItem as? TimelineItem.Event
// Scroll to bottom if the new event is from me, even if sent from another device
@ -217,7 +260,7 @@ class TimelinePresenter @AssistedInject constructor(
val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)
if (eventId != null && eventId != lastReadReceiptId.value) {
lastReadReceiptId.value = eventId
timeline.sendReadReceipt(eventId = eventId, receiptType = readReceiptType)
room.liveTimeline.sendReadReceipt(eventId = eventId, receiptType = readReceiptType)
}
}
}
@ -231,8 +274,4 @@ class TimelinePresenter @AssistedInject constructor(
}
return null
}
private fun CoroutineScope.paginateBackwards() = launch {
timeline.paginateBackwards(BACK_PAGINATION_EVENT_LIMIT, BACK_PAGINATION_PAGE_SIZE)
}
}

View file

@ -20,7 +20,6 @@ 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.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import kotlinx.collections.immutable.ImmutableList
@Immutable
@ -28,15 +27,28 @@ data class TimelineState(
val timelineItems: ImmutableList<TimelineItem>,
val timelineRoomInfo: TimelineRoomInfo,
val renderReadReceipts: Boolean,
val highlightedEventId: EventId?,
val paginationState: MatrixTimeline.PaginationState,
val newEventState: NewEventState,
val eventSink: (TimelineEvents) -> Unit
)
val isLive: Boolean,
val focusedEventId: EventId?,
val focusRequestState: FocusRequestState,
val eventSink: (TimelineEvents) -> Unit,
) {
val hasAnyEvent = timelineItems.any { it is TimelineItem.Event }
}
@Immutable
sealed interface FocusRequestState {
data object None : FocusRequestState
data class Cached(val index: Int) : FocusRequestState
data object Fetching : FocusRequestState
data object Fetched : FocusRequestState
data class Failure(val throwable: Throwable) : FocusRequestState
}
@Immutable
data class TimelineRoomInfo(
val isDm: Boolean,
val name: String?,
val userHasPermissionToSendMessage: Boolean,
val userHasPermissionToSendReaction: Boolean,
)

View file

@ -35,7 +35,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId
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 kotlinx.collections.immutable.ImmutableList
@ -47,32 +46,22 @@ import kotlin.random.Random
fun aTimelineState(
timelineItems: ImmutableList<TimelineItem> = persistentListOf(),
paginationState: MatrixTimeline.PaginationState = aPaginationState(),
renderReadReceipts: Boolean = false,
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
focusedEventIndex: Int = -1,
isLive: Boolean = true,
eventSink: (TimelineEvents) -> Unit = {},
) = TimelineState(
timelineItems = timelineItems,
timelineRoomInfo = timelineRoomInfo,
paginationState = paginationState,
renderReadReceipts = renderReadReceipts,
highlightedEventId = null,
newEventState = NewEventState.None,
isLive = isLive,
focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId,
focusRequestState = FocusRequestState.None,
eventSink = eventSink,
)
fun aPaginationState(
isBackPaginating: Boolean = false,
hasMoreToLoadBackwards: Boolean = true,
beginningOfRoomReached: Boolean = false,
): MatrixTimeline.PaginationState {
return MatrixTimeline.PaginationState(
isBackPaginating = isBackPaginating,
hasMoreToLoadBackwards = hasMoreToLoadBackwards,
beginningOfRoomReached = beginningOfRoomReached,
)
}
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
return persistentListOf(
// 3 items (First Middle Last) with isMine = false
@ -235,10 +224,12 @@ internal fun aGroupedEvents(
}
internal fun aTimelineRoomInfo(
name: String = "Room name",
isDm: Boolean = false,
userHasPermissionToSendMessage: Boolean = true,
) = TimelineRoomInfo(
isDm = isDm,
name = name,
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToSendReaction = true,
)

View file

@ -55,10 +55,9 @@ 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.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.focus.FocusRequestStateView
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
@ -74,12 +73,12 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
import kotlin.math.abs
@Composable
fun TimelineView(
state: TimelineState,
typingNotificationState: TypingNotificationState,
roomName: String?,
onUserDataClicked: (UserId) -> Unit,
onLinkClicked: (String) -> Unit,
onMessageClicked: (TimelineItem.Event) -> Unit,
@ -93,8 +92,8 @@ fun TimelineView(
modifier: Modifier = Modifier,
forceJumpToBottomVisibility: Boolean = false
) {
fun onReachedLoadMore() {
state.eventSink(TimelineEvents.LoadMore)
fun clearFocusRequestState() {
state.eventSink(TimelineEvents.ClearFocusRequestState)
}
fun onScrollFinishedAt(firstVisibleIndex: Int) {
@ -109,9 +108,8 @@ fun TimelineView(
accessibilityManager.isTouchExplorationEnabled.not()
}
@Suppress("UNUSED_PARAMETER")
fun inReplyToClicked(eventId: EventId) {
// TODO implement this logic once we have support to 'jump to event X' in sliding sync
state.eventSink(TimelineEvents.FocusOnEvent(eventId))
}
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
@ -123,8 +121,10 @@ fun TimelineView(
reverseLayout = useReverseLayout,
contentPadding = PaddingValues(vertical = 8.dp),
) {
item {
TypingNotificationView(state = typingNotificationState)
if (state.isLive) {
item {
TypingNotificationView(state = typingNotificationState)
}
}
items(
items = state.timelineItems,
@ -137,7 +137,7 @@ fun TimelineView(
renderReadReceipts = state.renderReadReceipts,
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true &&
state.timelineItems.first().identifier() == timelineItem.identifier(),
highlightedItem = state.highlightedEventId?.value,
focusedEventId = state.focusedEventId,
onClick = onMessageClicked,
onLongClick = onMessageLongClicked,
onUserDataClick = onUserDataClicked,
@ -152,28 +152,23 @@ fun TimelineView(
onSwipeToReply = onSwipeToReply,
)
}
if (state.paginationState.hasMoreToLoadBackwards) {
// Do not use key parameter to avoid wrong positioning
item(contentType = "TimelineLoadingMoreIndicator") {
TimelineLoadingMoreIndicator()
LaunchedEffect(Unit) {
onReachedLoadMore()
}
}
}
if (state.paginationState.beginningOfRoomReached && !state.timelineRoomInfo.isDm) {
item(contentType = "BeginningOfRoomReached") {
TimelineItemRoomBeginningView(roomName = roomName)
}
}
}
FocusRequestStateView(
focusRequestState = state.focusRequestState,
onClearFocusRequestState = ::clearFocusRequestState
)
TimelineScrollHelper(
isTimelineEmpty = state.timelineItems.isEmpty(),
hasAnyEvent = state.hasAnyEvent,
lazyListState = lazyListState,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
newEventState = state.newEventState,
onScrollFinishedAt = ::onScrollFinishedAt
isLive = state.isLive,
focusRequestState = state.focusRequestState,
onScrollFinishedAt = ::onScrollFinishedAt,
onClearFocusRequestState = ::clearFocusRequestState,
onJumpToLive = { state.eventSink(TimelineEvents.JumpToLive) },
)
}
}
@ -181,17 +176,21 @@ fun TimelineView(
@Composable
private fun BoxScope.TimelineScrollHelper(
isTimelineEmpty: Boolean,
hasAnyEvent: Boolean,
lazyListState: LazyListState,
newEventState: NewEventState,
isLive: Boolean,
forceJumpToBottomVisibility: Boolean,
focusRequestState: FocusRequestState,
onClearFocusRequestState: () -> Unit,
onScrollFinishedAt: (Int) -> Unit,
onJumpToLive: () -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
val canAutoScroll by remember {
derivedStateOf {
lazyListState.firstVisibleItemIndex < 3
lazyListState.firstVisibleItemIndex < 3 && isLive
}
}
@ -205,16 +204,36 @@ private fun BoxScope.TimelineScrollHelper(
}
}
fun jumpToBottom() {
if (isLive) {
scrollToBottom()
} else {
onJumpToLive()
}
}
val latestOnClearFocusRequestState by rememberUpdatedState(onClearFocusRequestState)
LaunchedEffect(focusRequestState) {
if (focusRequestState is FocusRequestState.Cached) {
if (abs(lazyListState.firstVisibleItemIndex - focusRequestState.index) < 10) {
lazyListState.animateScrollToItem(focusRequestState.index)
} else {
lazyListState.scrollToItem(focusRequestState.index)
}
latestOnClearFocusRequestState()
}
}
LaunchedEffect(canAutoScroll, newEventState) {
val shouldAutoScroll = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe)
if (shouldAutoScroll) {
val shouldScrollToBottom = isScrollFinished && (canAutoScroll || newEventState == NewEventState.FromMe)
if (shouldScrollToBottom) {
scrollToBottom()
}
}
val latestOnScrollFinishedAt by rememberUpdatedState(onScrollFinishedAt)
LaunchedEffect(isScrollFinished, isTimelineEmpty) {
if (isScrollFinished && !isTimelineEmpty) {
LaunchedEffect(isScrollFinished, hasAnyEvent) {
if (isScrollFinished && hasAnyEvent) {
// Notify the parent composable about the first visible item index when scrolling finishes
latestOnScrollFinishedAt(lazyListState.firstVisibleItemIndex)
}
@ -222,11 +241,11 @@ private fun BoxScope.TimelineScrollHelper(
JumpToBottomButton(
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
isVisible = !canAutoScroll || forceJumpToBottomVisibility,
isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 12.dp),
onClick = ::scrollToBottom,
onClick = { jumpToBottom() },
)
}
@ -271,18 +290,20 @@ internal fun TimelineViewPreview(
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
) {
TimelineView(
state = aTimelineState(timelineItems),
roomName = null,
state = aTimelineState(
timelineItems = timelineItems,
focusedEventIndex = 0,
),
typingNotificationState = aTypingNotificationState(),
onMessageClicked = {},
onTimestampClicked = {},
onUserDataClicked = {},
onLinkClicked = {},
onMessageClicked = {},
onMessageLongClicked = {},
onTimestampClicked = {},
onSwipeToReply = {},
onReactionClicked = { _, _ -> },
onReactionLongClicked = { _, _ -> },
onMoreReactionsClicked = {},
onSwipeToReply = {},
onReadReceiptClick = {},
forceJumpToBottomVisibility = true,
)

View file

@ -24,7 +24,6 @@ import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@ -93,7 +92,9 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
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.event.canBeRepliedTo
import io.element.android.features.messages.impl.timeline.model.eventId
import io.element.android.features.messages.impl.timeline.model.metadata
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
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
@ -146,7 +147,7 @@ fun TimelineItemEventRow(
}
fun inReplyToClicked() {
val inReplyToEventId = event.inReplyTo?.eventId ?: return
val inReplyToEventId = event.inReplyTo?.eventId() ?: return
inReplyToClick(inReplyToEventId)
}
@ -417,7 +418,6 @@ private fun MessageSenderInformation(
private fun MessageEventBubbleContent(
event: TimelineItem.Event,
onMessageLongClick: () -> Unit,
@Suppress("UNUSED_PARAMETER")
inReplyToClick: () -> Unit,
onTimestampClicked: () -> Unit,
onLinkClicked: (String) -> Unit,
@ -437,7 +437,7 @@ private fun MessageEventBubbleContent(
) {
Row(
modifier = modifier,
horizontalArrangement = spacedBy(4.dp, Alignment.Start),
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.Start),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
@ -565,16 +565,30 @@ private fun MessageEventBubbleContent(
}
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
ReplyToContent(
senderId = inReplyTo.senderId,
senderProfile = inReplyTo.senderProfile,
metadata = inReplyTo.metadata(),
modifier = Modifier
.padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(6.dp))
val inReplyToModifier = Modifier
.padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(6.dp))
// FIXME when a node is clickable, its contents won't be added to the semantics tree of its parent
// .clickable(enabled = true, onClick = inReplyToClick)
)
.clickable(onClick = inReplyToClick)
when (inReplyTo) {
is InReplyToDetails.Ready -> {
ReplyToContent(
senderId = inReplyTo.senderId,
senderProfile = inReplyTo.senderProfile,
metadata = inReplyTo.metadata(),
modifier = inReplyToModifier,
)
}
is InReplyToDetails.Error ->
ReplyToErrorContent(
data = inReplyTo,
modifier = inReplyToModifier,
)
is InReplyToDetails.Loading ->
ReplyToLoadingContent(
modifier = inReplyToModifier,
)
}
}
if (inReplyToDetails != null) {
// Use SubComposeLayout only if necessary as it can have consequences on the performance.
@ -584,7 +598,7 @@ private fun MessageEventBubbleContent(
contentWithTimestamp()
}
} else {
Column(modifier = modifier, verticalArrangement = spacedBy(8.dp)) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
threadDecoration()
contentWithTimestamp()
}
@ -652,6 +666,44 @@ private fun ReplyToContent(
}
}
@Composable
private fun ReplyToLoadingContent(
modifier: Modifier = Modifier,
) {
val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
Row(
modifier
.background(MaterialTheme.colorScheme.surface)
.padding(paddings)
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
PlaceholderAtom(width = 80.dp, height = 12.dp)
PlaceholderAtom(width = 140.dp, height = 14.dp)
}
}
}
@Composable
private fun ReplyToErrorContent(
data: InReplyToDetails.Error,
modifier: Modifier = Modifier,
) {
val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
Row(
modifier
.background(MaterialTheme.colorScheme.surface)
.padding(paddings)
) {
Text(
text = data.message,
style = ElementTheme.typography.fontBodyMdRegular,
color = MaterialTheme.colorScheme.error,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun ReplyToContentText(metadata: InReplyToMetadata?) {
val text = when (metadata) {

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2024 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.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
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
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowWithReplyOtherPreview(
@PreviewParameter(InReplyToDetailsOtherProvider::class) inReplyToDetails: InReplyToDetails,
) = ElementPreview {
TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails)
}
class InReplyToDetailsOtherProvider : InReplyToDetailsProvider() {
override val values: Sequence<InReplyToDetails>
get() = sequenceOf(
InReplyToDetails.Loading(eventId = EventId("\$anEventId")),
InReplyToDetails.Error(eventId = EventId("\$anEventId"), message = "An error message."),
)
}

View file

@ -170,7 +170,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
protected fun aInReplyToDetails(
eventContent: EventContent,
displayNameAmbiguous: Boolean = false,
) = InReplyToDetails(
) = InReplyToDetails.Ready(
eventId = EventId("\$event"),
eventContent = eventContent,
senderId = UserId("@Sender:domain"),

View file

@ -43,7 +43,7 @@ fun TimelineItemGroupedEventsRow(
timelineRoomInfo: TimelineRoomInfo,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
highlightedItem: String?,
focusedEventId: EventId?,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
@ -68,7 +68,7 @@ fun TimelineItemGroupedEventsRow(
onExpandGroupClick = ::onExpandGroupClick,
timelineItem = timelineItem,
timelineRoomInfo = timelineRoomInfo,
highlightedItem = highlightedItem,
focusedEventId = focusedEventId,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
onClick = onClick,
@ -92,7 +92,7 @@ private fun TimelineItemGroupedEventsRowContent(
onExpandGroupClick: () -> Unit,
timelineItem: TimelineItem.GroupedEvents,
timelineRoomInfo: TimelineRoomInfo,
highlightedItem: String?,
focusedEventId: EventId?,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
onClick: (TimelineItem.Event) -> Unit,
@ -116,7 +116,7 @@ private fun TimelineItemGroupedEventsRowContent(
timelineItem.events.size
),
isExpanded = isExpanded,
isHighlighted = !isExpanded && timelineItem.events.any { it.identifier() == highlightedItem },
isHighlighted = !isExpanded && timelineItem.events.any { it.isEvent(focusedEventId) },
onClick = onExpandGroupClick,
)
if (isExpanded) {
@ -127,7 +127,7 @@ private fun TimelineItemGroupedEventsRowContent(
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem,
focusedEventId = focusedEventId,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
@ -160,12 +160,13 @@ private fun TimelineItemGroupedEventsRowContent(
@PreviewsDayNight
@Composable
internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPreview {
val events = aGroupedEvents(withReadReceipts = true)
TimelineItemGroupedEventsRowContent(
isExpanded = true,
onExpandGroupClick = {},
timelineItem = aGroupedEvents(withReadReceipts = true),
timelineItem = events,
timelineRoomInfo = aTimelineRoomInfo(),
highlightedItem = null,
focusedEventId = events.events.first().eventId,
renderReadReceipts = true,
isLastOutgoingMessage = false,
onClick = {},
@ -190,7 +191,7 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
onExpandGroupClick = {},
timelineItem = aGroupedEvents(withReadReceipts = true),
timelineRoomInfo = aTimelineRoomInfo(),
highlightedItem = null,
focusedEventId = null,
renderReadReceipts = true,
isLastOutgoingMessage = false,
onClick = {},

View file

@ -16,13 +16,24 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
@ -32,7 +43,7 @@ internal fun TimelineItemRow(
timelineRoomInfo: TimelineRoomInfo,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
highlightedItem: String?,
focusedEventId: EventId?,
onUserDataClick: (UserId) -> Unit,
onLinkClicked: (String) -> Unit,
onClick: (TimelineItem.Event) -> Unit,
@ -47,69 +58,111 @@ internal fun TimelineItemRow(
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier
) {
when (timelineItem) {
is TimelineItem.Virtual -> {
TimelineItemVirtualRow(
virtual = timelineItem,
modifier = modifier,
)
val backgroundModifier = if (timelineItem.isEvent(focusedEventId)) {
val focusedEventOffset = if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) {
14.dp
} else {
2.dp
}
is TimelineItem.Event -> {
if (timelineItem.content is TimelineItemStateContent || timelineItem.content is TimelineItemLegacyCallInviteContent) {
TimelineItemStateEventRow(
event = timelineItem,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = highlightedItem == timelineItem.identifier(),
onClick = { onClick(timelineItem) },
onReadReceiptsClick = onReadReceiptClick,
onLongClick = { onLongClick(timelineItem) },
Modifier.focusedEvent(focusedEventOffset)
} else {
Modifier
}
Box(modifier = modifier.then(backgroundModifier)) {
when (timelineItem) {
is TimelineItem.Virtual -> {
TimelineItemVirtualRow(
virtual = timelineItem,
timelineRoomInfo = timelineRoomInfo,
eventSink = eventSink,
modifier = modifier,
)
} else {
TimelineItemEventRow(
event = timelineItem,
}
is TimelineItem.Event -> {
if (timelineItem.content is TimelineItemStateContent || timelineItem.content is TimelineItemLegacyCallInviteContent) {
TimelineItemStateEventRow(
event = timelineItem,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = timelineItem.isEvent(focusedEventId),
onClick = { onClick(timelineItem) },
onReadReceiptsClick = onReadReceiptClick,
onLongClick = { onLongClick(timelineItem) },
eventSink = eventSink,
)
} else {
TimelineItemEventRow(
event = timelineItem,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = timelineItem.isEvent(focusedEventId),
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
onUserDataClick = onUserDataClick,
onLinkClicked = onLinkClicked,
inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onTimestampClicked = onTimestampClicked,
onSwipeToReply = { onSwipeToReply(timelineItem) },
eventSink = eventSink,
)
}
}
is TimelineItem.GroupedEvents -> {
TimelineItemGroupedEventsRow(
timelineItem = timelineItem,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = highlightedItem == timelineItem.identifier(),
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
focusedEventId = focusedEventId,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onLinkClicked = onLinkClicked,
inReplyToClick = inReplyToClick,
onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onTimestampClicked = onTimestampClicked,
onSwipeToReply = { onSwipeToReply(timelineItem) },
eventSink = eventSink,
modifier = modifier,
)
}
}
is TimelineItem.GroupedEvents -> {
TimelineItemGroupedEventsRow(
timelineItem = timelineItem,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onLinkClicked = onLinkClicked,
onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
eventSink = eventSink,
modifier = modifier,
)
}
}
}
@Suppress("ModifierComposable")
@Composable
private fun Modifier.focusedEvent(
focusedEventOffset: Dp
): Modifier {
val highlightedLineColor = ElementTheme.colors.textActionAccent
val gradientColors = listOf(
ElementTheme.colors.highlightedMessageBackgroundColor,
ElementTheme.materialColors.background
)
val verticalOffset = focusedEventOffset.toPx()
val verticalRatio = 0.7f
return drawWithCache {
val brush = Brush.verticalGradient(
colors = gradientColors,
endY = size.height * verticalRatio,
)
onDrawBehind {
drawRect(
brush,
topLeft = Offset(0f, verticalOffset),
size = Size(size.width, size.height * verticalRatio)
)
drawLine(
highlightedLineColor,
start = Offset(0f, verticalOffset),
end = Offset(size.width, verticalOffset)
)
}
}.padding(top = 4.dp)
}

View file

@ -16,24 +16,51 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
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.TimelineItemReadMarkerView
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.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.TimelineItemLastForwardIndicatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel
@Composable
fun TimelineItemVirtualRow(
virtual: TimelineItem.Virtual,
timelineRoomInfo: TimelineRoomInfo,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier
) {
when (virtual.model) {
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier)
Box(modifier = modifier) {
when (virtual.model) {
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model)
TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView()
TimelineItemRoomBeginningModel -> TimelineItemRoomBeginningView(roomName = timelineRoomInfo.name)
is TimelineItemLoadingIndicatorModel -> {
TimelineLoadingMoreIndicator(virtual.model.direction)
val latestEventSink by rememberUpdatedState(eventSink)
LaunchedEffect(virtual.model.timestamp) {
latestEventSink(TimelineEvents.LoadMore(virtual.model.direction))
}
}
is TimelineItemLastForwardIndicatorModel -> {
Spacer(modifier = Modifier)
}
}
}
}

View file

@ -16,10 +16,12 @@
package io.element.android.features.messages.impl.timeline.components.virtual
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -27,24 +29,45 @@ import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
import io.element.android.libraries.matrix.api.timeline.Timeline
@Composable
internal fun TimelineLoadingMoreIndicator(modifier: Modifier = Modifier) {
internal fun TimelineLoadingMoreIndicator(
direction: Timeline.PaginationDirection,
modifier: Modifier = Modifier
) {
Box(
modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(8.dp),
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
strokeWidth = 2.dp,
)
when (direction) {
Timeline.PaginationDirection.FORWARDS -> {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp)
.height(1.dp)
)
}
Timeline.PaginationDirection.BACKWARDS -> {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineLoadingMoreIndicatorPreview() = ElementPreview {
TimelineLoadingMoreIndicator()
Column(
modifier = Modifier.padding(vertical = 2.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TimelineLoadingMoreIndicator(Timeline.PaginationDirection.BACKWARDS)
TimelineLoadingMoreIndicator(Timeline.PaginationDirection.FORWARDS)
}
}

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.factories
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
import io.element.android.features.messages.impl.timeline.diff.TimelineItemsCacheInvalidator
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
@ -43,9 +44,9 @@ class TimelineItemsFactory @Inject constructor(
private val eventItemFactory: TimelineItemEventFactory,
private val virtualItemFactory: TimelineItemVirtualFactory,
private val timelineItemGrouper: TimelineItemGrouper,
private val timelineItemIndexer: TimelineItemIndexer,
) {
private val timelineItems = MutableStateFlow(persistentListOf<TimelineItem>())
private val lock = Mutex()
private val diffCache = MutableListDiffCache<TimelineItem>()
private val diffCacheUpdater = DiffCacheUpdater<MatrixTimelineItem, TimelineItem>(
@ -100,6 +101,7 @@ class TimelineItemsFactory @Inject constructor(
}
}
val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList()
timelineItemIndexer.process(result)
this.timelineItems.emit(result)
}
@ -108,13 +110,13 @@ class TimelineItemsFactory @Inject constructor(
index: Int,
roomMembers: List<RoomMember>,
): TimelineItem? {
val timelineItemState =
val timelineItem =
when (val currentTimelineItem = timelineItems[index]) {
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers)
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
MatrixTimelineItem.Other -> null
}
diffCache[index] = timelineItemState
return timelineItemState
diffCache[index] = timelineItem
return timelineItem
}
}

View file

@ -18,7 +18,10 @@ 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.TimelineItemLastForwardIndicatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
@ -41,6 +44,12 @@ class TimelineItemVirtualFactory @Inject constructor(
is VirtualTimelineItem.DayDivider -> daySeparatorFactory.create(inner)
is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel
is VirtualTimelineItem.EncryptedHistoryBanner -> TimelineItemEncryptedHistoryBannerVirtualModel
is VirtualTimelineItem.RoomBeginning -> TimelineItemRoomBeginningModel
is VirtualTimelineItem.LoadingIndicator -> TimelineItemLoadingIndicatorModel(
direction = inner.direction,
timestamp = inner.timestamp
)
is VirtualTimelineItem.LastForwardIndicator -> TimelineItemLastForwardIndicatorModel
}
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2024 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.focus
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.timeline.FocusRequestState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.errors.FocusEventException
open class FocusRequestStateProvider : PreviewParameterProvider<FocusRequestState> {
override val values: Sequence<FocusRequestState>
get() = sequenceOf(
FocusRequestState.Fetching,
FocusRequestState.Failure(
FocusEventException.EventNotFound(
eventId = EventId("\$anEventId"),
)
),
FocusRequestState.Failure(
FocusEventException.InvalidEventId(
eventId = "invalid",
err = "An error"
)
),
FocusRequestState.Failure(
FocusEventException.Other(
msg = "An error"
)
),
)
}

View file

@ -0,0 +1,67 @@
/*
* Copyright (c) 2024 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.focus
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.FocusRequestState
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.room.errors.FocusEventException
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun FocusRequestStateView(
focusRequestState: FocusRequestState,
onClearFocusRequestState: () -> Unit,
modifier: Modifier = Modifier,
) {
when (focusRequestState) {
is FocusRequestState.Failure -> {
val errorMessage = when (focusRequestState.throwable) {
is FocusEventException.EventNotFound,
is FocusEventException.InvalidEventId -> stringResource(id = CommonStrings.error_message_not_found)
is FocusEventException.Other -> stringResource(id = CommonStrings.error_unknown)
else -> stringResource(id = CommonStrings.error_unknown)
}
ErrorDialog(
content = errorMessage,
onDismiss = onClearFocusRequestState,
modifier = modifier,
)
}
FocusRequestState.Fetching -> {
ProgressDialog(modifier = modifier, onDismissRequest = onClearFocusRequestState)
}
else -> Unit
}
}
@PreviewsDayNight
@Composable
internal fun FocusRequestStateViewPreview(
@PreviewParameter(FocusRequestStateProvider::class) state: FocusRequestState,
) = ElementPreview {
FocusRequestStateView(
focusRequestState = state,
onClearFocusRequestState = {},
)
}

View file

@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.timeline.model
import androidx.compose.runtime.Immutable
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.permalink.PermalinkParser
@ -27,18 +28,30 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StickerConten
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.ui.messages.toPlainText
data class InReplyToDetails(
val eventId: EventId,
val senderId: UserId,
val senderProfile: ProfileTimelineDetails,
val eventContent: EventContent?,
val textContent: String?,
)
@Immutable
sealed interface InReplyToDetails {
data class Ready(
val eventId: EventId,
val senderId: UserId,
val senderProfile: ProfileTimelineDetails,
val eventContent: EventContent?,
val textContent: String?,
) : InReplyToDetails
data class Loading(val eventId: EventId) : InReplyToDetails
data class Error(val eventId: EventId, val message: String) : InReplyToDetails
}
fun InReplyToDetails.eventId() = when (this) {
is InReplyToDetails.Ready -> eventId
is InReplyToDetails.Loading -> eventId
is InReplyToDetails.Error -> eventId
}
fun InReplyTo.map(
permalinkParser: PermalinkParser,
) = when (this) {
is InReplyTo.Ready -> InReplyToDetails(
is InReplyTo.Ready -> InReplyToDetails.Ready(
eventId = eventId,
senderId = senderId,
senderProfile = senderProfile,
@ -55,5 +68,7 @@ fun InReplyTo.map(
else -> null
}
)
else -> null
is InReplyTo.Error -> InReplyToDetails.Error(eventId, message)
is InReplyTo.NotLoaded -> InReplyToDetails.Loading(eventId)
is InReplyTo.Pending -> InReplyToDetails.Loading(eventId)
}

View file

@ -66,7 +66,7 @@ internal sealed interface InReplyToMetadata {
* Metadata can be either a thumbnail with a text OR just a text.
*/
@Composable
internal fun InReplyToDetails.metadata(): InReplyToMetadata? = when (eventContent) {
internal fun InReplyToDetails.Ready.metadata(): InReplyToMetadata? = when (eventContent) {
is MessageContent -> when (val type = eventContent.type) {
is ImageMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(

View file

@ -40,6 +40,14 @@ sealed interface TimelineItem {
is GroupedEvents -> id
}
fun isEvent(eventId: EventId?): Boolean {
if (eventId == null) return false
return when (this) {
is Event -> this.eventId == eventId
else -> false
}
}
fun contentType(): String = when (this) {
is Event -> content.type
is Virtual -> model.type

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2024 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
data object TimelineItemLastForwardIndicatorModel : TimelineItemVirtualModel {
override val type: String = "TimelineItemLastForwardIndicatorModel"
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2024 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
import io.element.android.libraries.matrix.api.timeline.Timeline
data class TimelineItemLoadingIndicatorModel(
val direction: Timeline.PaginationDirection,
val timestamp: Long,
) : TimelineItemVirtualModel {
override val type: String = "TimelineItemLoadingIndicatorModel"
}

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2024 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
data object TimelineItemRoomBeginningModel : TimelineItemVirtualModel {
override val type: String = "TimelineItemRoomBeginningModel"
}