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

commonMain.net.folivo.trixnity.client.room.RoomListHandler.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.*
import kotlinx.coroutines.flow.*
import kotlinx.datetime.Instant
import net.folivo.trixnity.client.MatrixClientConfiguration
import net.folivo.trixnity.client.store.*
import net.folivo.trixnity.client.utils.filterContent
import net.folivo.trixnity.clientserverapi.client.MatrixClientServerApiClient
import net.folivo.trixnity.clientserverapi.client.SyncEvents
import net.folivo.trixnity.clientserverapi.model.sync.Sync
import net.folivo.trixnity.core.ClientEventEmitter.Priority
import net.folivo.trixnity.core.EventHandler
import net.folivo.trixnity.core.model.RoomId
import net.folivo.trixnity.core.model.events.ClientEvent
import net.folivo.trixnity.core.model.events.m.DirectEventContent
import net.folivo.trixnity.core.model.events.m.room.*
import net.folivo.trixnity.core.model.events.roomIdOrNull
import net.folivo.trixnity.core.model.events.stateKeyOrNull
import net.folivo.trixnity.core.unsubscribeOnCompletion
import net.folivo.trixnity.utils.ConcurrentList
import net.folivo.trixnity.utils.ConcurrentMap
import net.folivo.trixnity.utils.concurrentMutableList
import net.folivo.trixnity.utils.concurrentMutableMap

private val log = KotlinLogging.logger {}

class RoomListHandler(
    private val api: MatrixClientServerApiClient,
    private val roomStore: RoomStore,
    private val roomStateStore: RoomStateStore,
    private val globalAccountDataStore: GlobalAccountDataStore,
    private val forgetRoomService: ForgetRoomService,
    private val tm: TransactionManager,
    private val config: MatrixClientConfiguration,
) : EventHandler {

    override fun startInCoroutineScope(scope: CoroutineScope) {
        api.sync.subscribe(Priority.ROOM_LIST, ::updateRoomList).unsubscribeOnCompletion(scope)
        api.sync.subscribe(Priority.AFTER_DEFAULT - 1, ::deleteLeftRooms).unsubscribeOnCompletion(scope)
    }

    internal suspend fun updateRoomList(syncEvents: SyncEvents) = coroutineScope {
        val roomUpdates: ConcurrentMap Room?>> = concurrentMutableMap()

        val syncRooms = syncEvents.syncResponse.room

        val createEvents =
            async(start = CoroutineStart.LAZY) {
                syncEvents
                    .filterContent()
                    .mapNotNull { event -> event.roomIdOrNull?.let { it to event.content } }
                    .toList()
                    .toMap()
            }
        val nameEvents =
            async(start = CoroutineStart.LAZY) {
                syncEvents
                    .filterContent()
                    .mapNotNull { event -> event.roomIdOrNull?.let { it to event.content } }
                    .toList()
                    .toMap()
            }
        val canonicalAliasEvents =
            async(start = CoroutineStart.LAZY) {
                syncEvents
                    .filterContent()
                    .mapNotNull { event -> event.roomIdOrNull?.let { it to event.content } }
                    .toList()
                    .toMap()
            }
        val encryptionEnabled =
            async(start = CoroutineStart.LAZY) {
                syncEvents
                    .filterContent()
                    .mapNotNull { it.roomIdOrNull }
                    .toSet()
            }
        val nextRoomIds =
            async(start = CoroutineStart.LAZY) {
                syncEvents
                    .filterContent()
                    .mapNotNull { event -> event.roomIdOrNull?.let { it to event.content.replacementRoom } }
                    .toList()
                    .toMap()
            }

        suspend fun createMergeRoom(
            roomId: RoomId,
            membership: Membership,
            lastRelevantEvent: ClientEvent.RoomEvent<*>?,
            unreadMessageCount: Long?,
            name: RoomDisplayName?,
        ): (Room?) -> Room {
            val createEventContent = createEvents.await()[roomId]
            val encrypted = encryptionEnabled.await().contains(roomId)
            val nextRoomId = nextRoomIds.await()[roomId]
            val lastRelevantEventTimestamp = lastRelevantEvent?.originTimestamp
                ?.let { Instant.fromEpochMilliseconds(it) }
            return { oldRoom ->
                (oldRoom ?: Room(roomId)).copy(
                    membership = membership,
                    createEventContent = createEventContent ?: oldRoom?.createEventContent,
                    encrypted = encrypted || oldRoom?.encrypted == true,
                    lastRelevantEventId = lastRelevantEvent?.id ?: oldRoom?.lastRelevantEventId,
                    lastRelevantEventTimestamp = lastRelevantEventTimestamp ?: oldRoom?.lastRelevantEventTimestamp,
                    unreadMessageCount = unreadMessageCount ?: oldRoom?.unreadMessageCount ?: 0,
                    nextRoomId = nextRoomId ?: oldRoom?.nextRoomId,
                    name = name ?: oldRoom?.name,
                )
            }
        }

        syncRooms?.join?.entries?.forEachParallel { (roomId, roomInfo) ->
            val unreadMessageCount = roomInfo.unreadNotifications?.notificationCount
            val lastRelevantEvent = roomInfo.timeline?.events?.lastOrNull { config.lastRelevantEventFilter(it) }
            val name = calculateDisplayName(
                roomId = roomId,
                nameEventContent = nameEvents.await()[roomId],
                canonicalAliasEventContent = canonicalAliasEvents.await()[roomId],
                roomSummary = roomInfo.summary
            )
            val mergeRoom = createMergeRoom(
                roomId = roomId,
                membership = Membership.JOIN,
                lastRelevantEvent = lastRelevantEvent,
                unreadMessageCount = unreadMessageCount,
                name = name,
            )
            roomUpdates.add(roomId) { mergeRoom(it) }
        }
        syncRooms?.leave?.entries?.forEachParallel { (roomId, roomInfo) ->
            val lastRelevantEvent = roomInfo.timeline?.events?.lastOrNull { config.lastRelevantEventFilter(it) }
            val mergeRoom = createMergeRoom(
                roomId = roomId,
                membership = Membership.LEAVE,
                lastRelevantEvent = lastRelevantEvent,
                unreadMessageCount = null,
                name = null,
            )
            roomUpdates.add(roomId) { mergeRoom(it) }
        }
        syncRooms?.knock?.entries?.forEachParallel { (roomId, _) ->
            val mergeRoom = createMergeRoom(
                roomId = roomId,
                membership = Membership.KNOCK,
                lastRelevantEvent = null,
                unreadMessageCount = null,
                name = null,
            )
            roomUpdates.add(roomId) { mergeRoom(it) }
        }
        syncRooms?.invite?.entries?.forEachParallel { (roomId, _) ->
            val mergeRoom = createMergeRoom(
                roomId = roomId,
                membership = Membership.INVITE,
                lastRelevantEvent = null,
                unreadMessageCount = null,
                name = null,
            )
            roomUpdates.add(roomId) { mergeRoom(it) }
        }

        updateIsDirectAndAvatarUrls(syncEvents, roomUpdates)

        tm.transaction {
            roomUpdates.read { toMap() }.forEach { (roomId, updates) ->
                roomStore.update(roomId) { oldRoom ->
                    updates.read { toList() }.fold(oldRoom) { room, update ->
                        update(room)
                    }
                }
            }
        }

        coroutineContext.cancelChildren()
    }

    private suspend fun updateIsDirectAndAvatarUrls(
        syncEvents: SyncEvents,
        roomUpdates: ConcurrentMap Room?>>,
    ) = coroutineScope {
        val directEvent = syncEvents.filterContent().firstOrNull()?.content
        val syncRooms = syncEvents.syncResponse.room

        val allRooms =
            async(start = CoroutineStart.LAZY) {
                [email protected]().first().keys +
                        syncRooms?.run {
                            this.join?.keys.orEmpty() +
                                    this.leave?.keys.orEmpty() +
                                    this.knock?.keys.orEmpty() +
                                    this.invite?.keys.orEmpty()
                        }.orEmpty()
            }
        val allDirectRooms =
            async(start = CoroutineStart.LAZY) {
                (directEvent ?: [email protected]().first()?.content)
                    ?.mapWithRoomIdKeys()
            }
        val avatarEvents =
            async(start = CoroutineStart.LAZY) {
                syncEvents
                    .filterContent()
                    .mapNotNull { event -> event.roomIdOrNull?.let { it to event.content } }
                    .toList()
                    .toMap()
            }
        val memberEvents =
            async(start = CoroutineStart.LAZY) {
                syncEvents
                    .filterContent()
                    .toList()
                    .groupBy { it.roomIdOrNull }
                    .mapNotNull { (key, value) -> if (key != null) key to value else null }
                    .toMap()
                    .mapValues { entry ->
                        entry.value.mapNotNull { event -> event.stateKeyOrNull?.let { it to event.content } }.toMap()
                    }
            }

        coroutineScope {
            if (directEvent != null) {
                launch {
                    log.debug { "update all rooms isDirect" }
                    val allDirectEventRooms = directEvent.mapWithRoomIdKeys()
                    allRooms.await().forEach { roomId ->
                        val isDirect = allDirectEventRooms[roomId]?.first() != null
                        roomUpdates.add(roomId) { oldRoom ->
                            oldRoom?.copy(isDirect = isDirect)
                        }
                    }
                }
            }

            val updateAvatarUrlRooms =
                if (directEvent != null) allRooms.await()
                else avatarEvents.await().keys +
                        memberEvents.await().keys.intersect(allDirectRooms.await()?.keys.orEmpty())

            if (updateAvatarUrlRooms.isNotEmpty()) {
                updateAvatarUrlRooms.forEach { roomId ->
                    launch {
                        log.debug { "update room avatar of room $roomId" }
                        val directUser = allDirectRooms.await()?.get(roomId)?.first()
                        val roomAvatarEvent =
                            avatarEvents.await()[roomId]
                                ?: roomStateStore.getByStateKey(roomId).first()?.content
                        val roomAvatarUrl = roomAvatarEvent?.url
                        val memberAvatarUrl =
                            if (directUser != null && roomAvatarUrl.isNullOrEmpty()) {
                                val memberEvent =
                                    memberEvents.await()[roomId]?.get(directUser.full)
                                        ?: roomStateStore.getByStateKey(roomId, directUser.full)
                                            .first()?.content
                                memberEvent?.avatarUrl
                            } else null
                        val avatarUrl = roomAvatarUrl?.ifEmpty { null } ?: memberAvatarUrl
                        roomUpdates.add(roomId) { oldRoom ->
                            oldRoom?.copy(
                                avatarUrl = avatarUrl,
                            )
                        }
                    }
                }
            }
        }

        coroutineContext.cancelChildren()
    }

    internal suspend fun calculateDisplayName(
        roomId: RoomId,
        nameEventContent: NameEventContent? = null,
        canonicalAliasEventContent: CanonicalAliasEventContent? = null,
        roomSummary: Sync.Response.Rooms.JoinedRoom.RoomSummary? = null,
    ): RoomDisplayName? {
        val oldRoomSummary = roomStore.get(roomId).first()?.name?.summary

        if (nameEventContent == null && canonicalAliasEventContent == null && roomSummary == oldRoomSummary) return null

        val mergedRoomSummary =
            if (roomSummary == null && roomSummary == oldRoomSummary) null
            else Sync.Response.Rooms.JoinedRoom.RoomSummary(
                heroes = roomSummary?.heroes ?: oldRoomSummary?.heroes,
                joinedMemberCount = roomSummary?.joinedMemberCount ?: oldRoomSummary?.joinedMemberCount,
                invitedMemberCount = roomSummary?.invitedMemberCount ?: oldRoomSummary?.invitedMemberCount,
            )

        val nameFromNameEvent = (nameEventContent
            ?: roomStateStore.getByStateKey(roomId).first()?.content)?.name
        val nameFromAliasEvent = (canonicalAliasEventContent
            ?: roomStateStore.getByStateKey(roomId).first()?.content)?.alias?.full

        val roomName = when {
            nameFromNameEvent.isNullOrEmpty().not() ->
                RoomDisplayName(explicitName = nameFromNameEvent, summary = mergedRoomSummary)

            nameFromAliasEvent.isNullOrEmpty().not() ->
                RoomDisplayName(explicitName = nameFromAliasEvent, summary = mergedRoomSummary)

            else -> {
                val heroes = mergedRoomSummary?.heroes
                val joinedMemberCount = mergedRoomSummary?.joinedMemberCount
                val invitedMemberCount = mergedRoomSummary?.invitedMemberCount
                if (heroes == null || joinedMemberCount == null || invitedMemberCount == null) {
                    log.debug { "calculate room display name cancelled, because there are missing information (e.g. due to an invite)" }
                    return null
                }
                val us = 1

                log.debug { "calculate room display name of $roomId (heroes=$heroes, joinedMemberCount=$joinedMemberCount, invitedMemberCount=$invitedMemberCount)" }

                if (joinedMemberCount + invitedMemberCount <= 1) {
                    // the room contains us or nobody
                    when {
                        heroes.isEmpty() -> RoomDisplayName(isEmpty = true, summary = mergedRoomSummary)
                        else -> {
                            val isCompletelyEmpty = joinedMemberCount + invitedMemberCount <= 0
                            val leftMembersCount =
                                roomStateStore.membersCount(
                                    roomId,
                                    Membership.LEAVE,
                                    Membership.BAN
                                ) - if (isCompletelyEmpty) us else 0
                            when {
                                leftMembersCount <= heroes.size ->
                                    RoomDisplayName(
                                        isEmpty = true,
                                        summary = mergedRoomSummary
                                    )

                                else -> {
                                    RoomDisplayName(
                                        isEmpty = true,
                                        otherUsersCount = leftMembersCount - heroes.size,
                                        summary = mergedRoomSummary
                                    )
                                }
                            }
                        }
                    }
                } else {
                    when {
                        //case ist not specified in the Spec, so this catches server misbehavior
                        heroes.isEmpty() ->
                            RoomDisplayName(
                                otherUsersCount = joinedMemberCount + invitedMemberCount - us,
                                summary = mergedRoomSummary
                            )

                        joinedMemberCount + invitedMemberCount - us <= heroes.size ->
                            RoomDisplayName(
                                summary = mergedRoomSummary
                            )

                        else ->
                            RoomDisplayName(
                                otherUsersCount = joinedMemberCount + invitedMemberCount - heroes.size - us,
                                summary = mergedRoomSummary
                            )
                    }
                }
            }
        }
        return roomName
    }

    internal suspend fun deleteLeftRooms(syncEvents: SyncEvents) {
        val syncLeaveRooms = syncEvents.syncResponse.room?.leave?.keys
        if (syncLeaveRooms != null && config.deleteRoomsOnLeave) {
            val existingLeaveRooms = roomStore.getAll().first()
                .filter { it.value.first()?.membership == Membership.LEAVE }
                .keys

            if ((existingLeaveRooms - syncLeaveRooms).isNotEmpty()) {
                log.warn { "there were LEAVE rooms which should have already been deleted (existingLeaveRooms=$existingLeaveRooms syncLeaveRooms=$syncLeaveRooms)" }
            }

            val forgetRooms = existingLeaveRooms + syncLeaveRooms

            log.trace { "existingLeaveRooms=$existingLeaveRooms syncLeaveRooms=$syncLeaveRooms" }
            if (forgetRooms.isNotEmpty()) {
                log.debug { "forget rooms: $forgetRooms" }
                tm.transaction {
                    forgetRooms.forEach { roomId ->
                        forgetRoomService(roomId)
                    }
                }
            }
        }
    }

    private suspend fun  Collection.forEachParallel(block: suspend (T) -> Unit) = coroutineScope {
        forEach { launch { block(it) } }
    }

    private suspend fun ConcurrentMap Room?>>.add(
        roomId: RoomId,
        value: (Room?) -> Room?
    ) = write {
        getOrPut(roomId) { concurrentMutableList() }.write { add(value) }
    }

    private fun DirectEventContent.mapWithRoomIdKeys() =
        mappings.entries
            .flatMap { entry -> entry.value?.map { it to entry.key }.orEmpty() }
            .groupBy { it.first }
            .mapValues { entry -> entry.value.map { it.second } }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy