All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.net.folivo.trixnity.client.room.Timeline.kt Maven / Gradle / Ivy

There is a newer version: 4.7.1
Show newest version
package net.folivo.trixnity.client.room

import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.job
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import net.folivo.trixnity.client.store.*
import net.folivo.trixnity.clientserverapi.model.rooms.GetEvents
import net.folivo.trixnity.core.model.EventId
import net.folivo.trixnity.core.model.RoomId
import net.folivo.trixnity.core.model.events.m.room.CreateEventContent
import net.folivo.trixnity.core.model.events.m.room.TombstoneEventContent
import kotlin.time.Duration.Companion.INFINITE

private val log = KotlinLogging.logger { }

typealias SimpleTimeline = Timeline>

/**
 * This is an abstraction for a timeline. Call [init] first!
 */
interface Timeline {
    /**
     * The current state of the timeline.
     */
    val state: Flow>

    /**
     * Initialize the timeline with the start event.
     *
     * Consider wrapping this method call in a timeout, since it might fetch the start event from the server if it is not found locally.
     *
     * The timeline can be initialized multiple times from different starting events.
     * If doing so, it must be ensured, that there is no running call to [loadBefore] or [loadAfter].
     * Otherwise [init] will suspend until [loadBefore] or [loadAfter] are finished.
     *
     * @param startFrom The event id to try start timeline generation from.
     * @param configStart The config for getting the [startFrom].
     * @param configBefore The config for getting [TimelineEvent]s before [startFrom].
     * @param configAfter The config for getting [TimelineEvent]s after [startFrom].
     */
    suspend fun init(
        startFrom: EventId,
        configStart: GetTimelineEventConfig.() -> Unit = {},
        configBefore: GetTimelineEventsConfig.() -> Unit = {},
        configAfter: GetTimelineEventsConfig.() -> Unit = {},
    ): TimelineStateChange

    /**
     * Load new events before the oldest event. With default config this may suspend until at least one event can be loaded.
     *
     * This will also suspend until [init] is finished.
     *
     * @param config The config for getting [TimelineEvent]s.
     */
    suspend fun loadBefore(config: GetTimelineEventsConfig.() -> Unit = {}): TimelineStateChange

    /**
     * Load new events after the newest event. With default config this may suspend until at least one event can be loaded.
     *
     * This will also suspend until [init] is finished.
     *
     * @param config The config for getting [TimelineEvent]s.
     */
    suspend fun loadAfter(config: GetTimelineEventsConfig.() -> Unit = {}): TimelineStateChange
}

data class TimelineState(
    /**
     * Elements sorted with higher indexes being more recent.
     */
    val elements: List = listOf(),

    /**
     * Lower bound of loaded events in this timeline.
     */
    val lastLoadedEventIdBefore: EventId? = null,

    /**
     * Upper bound of loaded events in this timeline.
     */
    val lastLoadedEventIdAfter: EventId? = null,

    /**
     * True when timeline initialization has been finished.
     */
    val isInitialized: Boolean = false,

    /**
     * True while events are loaded before.
     */
    val isLoadingBefore: Boolean = false,

    /**
     * True while events are loaded after.
     */
    val isLoadingAfter: Boolean = false,

    /**
     * Is true until start of timeline is reached.
     */
    val canLoadBefore: Boolean = false,

    /**
     * Is true until last known [TimelineEvent] is reached.
     */
    val canLoadAfter: Boolean = false,
)

data class TimelineStateChange(
    val elementsBeforeChange: List = listOf(),
    val elementsAfterChange: List = listOf(),
    val newElements: List = listOf(),
)

/**
 * An implementation for some restrictions required by [Timeline].
 *
 * Implementing this may be useful for tests (e.g. a TimelineMock).
 */
abstract class TimelineBase(
    val transformer: suspend (Flow) -> T,
) : Timeline {
    protected abstract suspend fun internalInit(
        startFrom: EventId,
        configStart: GetTimelineEventConfig.() -> Unit = {},
        configBefore: GetTimelineEventsConfig.() -> Unit,
        configAfter: GetTimelineEventsConfig.() -> Unit,
    ): List>

    protected abstract suspend fun internalLoadBefore(
        startFrom: EventId,
        config: GetTimelineEventsConfig.() -> Unit,
    ): List>

    protected abstract suspend fun internalLoadAfter(
        startFrom: EventId,
        config: GetTimelineEventsConfig.() -> Unit,
    ): List>

    protected abstract suspend fun Flow.canLoadBefore(): Flow
    protected abstract suspend fun Flow.canLoadAfter(): Flow


    private data class InternalState(
        val elements: List = listOf(),
        val lastLoadedEventBefore: Flow? = null,
        val lastLoadedEventAfter: Flow? = null,
        val isInitialized: Boolean = false,
        val isLoadingBefore: Boolean = false,
        val isLoadingAfter: Boolean = false,
    )

    private val internalState = MutableStateFlow(InternalState())

    @OptIn(ExperimentalCoroutinesApi::class)
    override val state: Flow> =
        internalState.flatMapLatest { internalState ->
            combine(
                internalState.lastLoadedEventBefore?.canLoadBefore() ?: flowOf(true),
                internalState.lastLoadedEventAfter?.canLoadAfter() ?: flowOf(true)
            ) { canLoadBefore, canLoadAfter ->
                TimelineState(
                    elements = internalState.elements,
                    lastLoadedEventIdBefore = internalState.lastLoadedEventBefore?.first()?.eventId,
                    lastLoadedEventIdAfter = internalState.lastLoadedEventAfter?.first()?.eventId,
                    isInitialized = internalState.isInitialized,
                    isLoadingBefore = internalState.isLoadingBefore,
                    isLoadingAfter = internalState.isLoadingAfter,
                    canLoadBefore = canLoadBefore,
                    canLoadAfter = canLoadAfter,
                )
            }
        }.distinctUntilChanged()

    private val loadBeforeMutex = Mutex()
    private val loadAfterMutex = Mutex()

    private suspend fun List>.transformToElements() = map { events -> transformer(events) }

    override suspend fun init(
        startFrom: EventId,
        configStart: GetTimelineEventConfig.() -> Unit,
        configBefore: GetTimelineEventsConfig.() -> Unit,
        configAfter: GetTimelineEventsConfig.() -> Unit,
    ): TimelineStateChange = coroutineScope {
        loadBeforeMutex.withLock {
            loadAfterMutex.withLock {
                internalState.update { it.copy(isInitialized = false) }
                val newEvents = internalInit(startFrom,
                    configStart = {
                        fetchTimeout = INFINITE
                        fetchSize = 100
                    },
                    configBefore = {
                        minSize = 1
                        maxSize = fetchSize / 2
                        configBefore()
                    },
                    configAfter = {
                        minSize = 1
                        maxSize = fetchSize / 2
                        configAfter()
                    })
                val newElements = newEvents.transformToElements()
                lateinit var elementsBeforeChange: List
                internalState.update {
                    elementsBeforeChange = it.elements
                    it.copy(
                        elements = newElements,
                        lastLoadedEventBefore = newEvents.firstOrNull(),
                        lastLoadedEventAfter = newEvents.lastOrNull(),
                        isInitialized = true,
                    )
                }
                TimelineStateChange(
                    elementsBeforeChange = elementsBeforeChange,
                    elementsAfterChange = newElements,
                    newElements = newElements
                )
            }
        }
    }

    override suspend fun loadBefore(config: GetTimelineEventsConfig.() -> Unit): TimelineStateChange =
        coroutineScope {
            internalState.first { it.isInitialized }
            loadBeforeMutex.withLock {
                val startFrom = internalState.value.lastLoadedEventBefore?.first()?.eventId
                    ?: throw IllegalStateException("Timeline not initialized")
                coroutineContext.job.invokeOnCompletion { error ->
                    if (error != null) internalState.update { it.copy(isLoadingBefore = false) }
                }
                internalState.update { it.copy(isLoadingBefore = true) }
                val newEvents = internalLoadBefore(startFrom) {
                    minSize = 2
                    maxSize = fetchSize
                    config()
                }
                val newElements = newEvents.transformToElements()
                lateinit var elementsBeforeChange: List
                lateinit var elementsAfterChange: List
                internalState.update {
                    elementsBeforeChange = it.elements
                    elementsAfterChange = newElements + it.elements
                    it.copy(
                        elements = elementsAfterChange,
                        lastLoadedEventBefore = newEvents.firstOrNull() ?: it.lastLoadedEventBefore,
                        isLoadingBefore = false
                    )
                }
                TimelineStateChange(
                    elementsBeforeChange = elementsBeforeChange,
                    elementsAfterChange = elementsAfterChange,
                    newElements = newElements
                )
            }
        }

    override suspend fun loadAfter(config: GetTimelineEventsConfig.() -> Unit): TimelineStateChange =
        coroutineScope {
            internalState.first { it.isInitialized }
            loadAfterMutex.withLock {
                val startFrom = internalState.value.lastLoadedEventAfter?.first()?.eventId
                    ?: throw IllegalStateException("Timeline not initialized")
                coroutineContext.job.invokeOnCompletion { error ->
                    if (error != null) internalState.update { it.copy(isLoadingAfter = false) }
                }
                internalState.update { it.copy(isLoadingAfter = true) }
                val newEvents = internalLoadAfter(startFrom) {
                    minSize = 2
                    maxSize = fetchSize
                    config()
                }
                val newElements = newEvents.transformToElements()
                lateinit var elementsBeforeChange: List
                lateinit var elementsAfterChange: List
                internalState.update {
                    elementsBeforeChange = it.elements
                    elementsAfterChange = it.elements + newElements
                    it.copy(
                        elements = elementsAfterChange,
                        lastLoadedEventAfter = newEvents.lastOrNull() ?: it.lastLoadedEventAfter,
                        isLoadingAfter = false,
                    )
                }
                TimelineStateChange(
                    elementsBeforeChange = elementsBeforeChange,
                    elementsAfterChange = elementsAfterChange,
                    newElements = newElements
                )
            }
        }
}

class TimelineImpl(
    private val roomId: RoomId,
    private val roomService: RoomService,
    transformer: suspend (Flow) -> T,
) : TimelineBase(transformer) {
    override suspend fun internalInit(
        startFrom: EventId,
        configStart: GetTimelineEventConfig.() -> Unit,
        configBefore: GetTimelineEventsConfig.() -> Unit,
        configAfter: GetTimelineEventsConfig.() -> Unit
    ): List> = coroutineScope {
        log.debug { "init timeline" }
        val startFromEvent = roomService.getTimelineEvent(
            eventId = startFrom,
            roomId = roomId,
            config = configStart
        ).filterNotNull()
            .also { it.first() } // wait until it exists in store
        val eventsBefore = async {
            log.debug { "load before $startFrom" }
            roomService.getTimelineEvents(
                startFrom = startFrom,
                roomId = roomId,
                direction = GetEvents.Direction.BACKWARDS,
                config = configBefore,
            ).drop(1).toList().reversed()
                .also { log.debug { "finished load before $startFrom" } }
        }
        val eventsAfter = async {
            log.debug { "load after $startFrom" }
            roomService.getTimelineEvents(
                startFrom = startFrom,
                roomId = roomId,
                direction = GetEvents.Direction.FORWARDS,
                config = configAfter,
            ).drop(1).toList()
                .also { log.debug { "finished load after $startFrom" } }
        }
        val newEvents = eventsBefore.await() + startFromEvent + eventsAfter.await()
        log.debug { "finished init timeline" }
        newEvents
    }

    override suspend fun internalLoadBefore(
        startFrom: EventId,
        config: GetTimelineEventsConfig.() -> Unit
    ): List> {
        log.debug { "load before $startFrom" }
        val newEvents = roomService.getTimelineEvents(
            startFrom = startFrom,
            roomId = roomId,
            direction = GetEvents.Direction.BACKWARDS,
            config = config,
        ).drop(1).toList().reversed().map { it.filterNotNull() }
        log.debug { "finished load before $startFrom" }
        return newEvents
    }

    override suspend fun internalLoadAfter(
        startFrom: EventId,
        config: GetTimelineEventsConfig.() -> Unit
    ): List> {
        log.debug { "load after $startFrom" }
        val newEvents = roomService.getTimelineEvents(
            startFrom = startFrom,
            roomId = roomId,
            direction = GetEvents.Direction.FORWARDS,
            config = config,
        ).drop(1).toList().map { it.filterNotNull() }
        log.debug { "finished load after $startFrom" }
        return newEvents
    }

    override suspend fun Flow.canLoadBefore(): Flow = map { timelineEvent ->
        val createEventContent = timelineEvent.event.content as? CreateEventContent
        timelineEvent.isFirst.not() || createEventContent?.predecessor != null
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    override suspend fun Flow.canLoadAfter(): Flow = flatMapLatest { timelineEvent ->
        roomService.getState(timelineEvent.roomId).map { tombstone ->
            timelineEvent.isLast.not() || tombstone != null
        }
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy