Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
Conversation.kt 29.78 KiB
/*
 *  Copyright (C) 2004-2024 Savoir-faire Linux Inc.
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with this program. If not, see <https://www.gnu.org/licenses/>.
 */
package net.jami.model

import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.subjects.BehaviorSubject
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.SingleSubject
import io.reactivex.rxjava3.subjects.Subject
import net.jami.utils.Log
import net.jami.utils.StringUtils
import java.util.*
import kotlin.collections.ArrayList

class Conversation : ConversationHistory {
    val accountId: String
    val uri: Uri
    val contacts: MutableList<Contact>
    private val rawHistory: NavigableMap<Long, Interaction> = TreeMap()
    private val currentCalls = ArrayList<Conference>()
    val aggregateHistory = ArrayList<Interaction>(32)

    val lastDisplayedMessages: MutableMap<String, String> = HashMap()
    private val updatedElementSubject: Subject<Pair<Interaction, ElementStatus>> = PublishSubject.create()
    private val clearedSubject: Subject<List<Interaction>> = PublishSubject.create()
    private val callsSubject: Subject<List<Conference>> = BehaviorSubject.createDefault(emptyList())
    private val activeCallsSubject: Subject<List<ActiveCall>> = BehaviorSubject.createDefault(emptyList())
    private val composingStatusSubject: Subject<Account.ComposingStatus> = BehaviorSubject.createDefault(Account.ComposingStatus.Idle)
    private val color: Subject<Int> = BehaviorSubject.createDefault(0)
    private val symbol: Subject<CharSequence> = BehaviorSubject.createDefault("")
    private val mContactSubject: Subject<List<Contact>> = BehaviorSubject.create()
    var loaded: Single<Conversation>? = null
    val lastElementLoadedSubject = SingleSubject.create<Completable>()
    val lastElementLoaded = lastElementLoadedSubject.flatMapCompletable { it }
    private val mMessages: MutableMap<String, Interaction> = HashMap(16)
    private val mPendingMessages: MutableMap<String, SingleSubject<Interaction>> = HashMap(8)
    var lastRead: String? = null
        private set
    var lastNotified: String? = null
        private set
    var lastSent: String? = null
        private set
    private val mMode: Subject<Mode>

    private val profileSubject: Subject<Single<Profile>> = BehaviorSubject.createDefault(Profile.EMPTY_PROFILE_SINGLE)
    val profile: Observable<Profile> = profileSubject.switchMapSingle { it }

    // runtime flag set to true if the user is currently viewing this conversation
    private var mVisible = false
    private val mVisibleSubject: Subject<Boolean> = BehaviorSubject.createDefault(mVisible)

    // indicate the list needs sorting
    private var mDirty = false
    private var mLoadingSubject: SingleSubject<Conversation>? = null

    var request:TrustRequest? = null

    val mode: Observable<Mode>
        get() = mMode
    val isSwarm: Boolean
        get() = Uri.SWARM_SCHEME == uri.scheme

    val contactUpdates: Observable<List<Contact>>
        get() = mContactSubject

    var loading: SingleSubject<Conversation>?
        get() = mLoadingSubject
        set(l) {
            mLoadingSubject?.let { loading ->
                if (!loading.hasValue() && !loading.hasThrowable())
                    loading.onError(IllegalStateException())
            }
            mLoadingSubject = l
        }

    enum class ElementStatus {
        UPDATE, REMOVE, ADD
    }

    val updatedElements: Observable<Pair<Interaction, ElementStatus>>
        get() = updatedElementSubject

    val cleared: Observable<List<Interaction>>
        get() = clearedSubject

    val calls: Observable<List<Conference>>
        get() = callsSubject

    val activeCalls: Observable<List<ActiveCall>>
        get() = activeCallsSubject

    val composingStatus: Observable<Account.ComposingStatus>
        get() = composingStatusSubject

    val sortedHistory: Single<List<Interaction>> = Single.fromCallable {
        sortHistory()
        ArrayList(aggregateHistory)
    }
    var lastEvent: Interaction? = null
        private set(e) {
            field = e
            if (e != null)
                lastEventSubject.onNext(e)
        }
    private val lastEventSubject: Subject<Interaction> = BehaviorSubject.create()
    val currentStateObservable: Observable<Pair<Interaction, Boolean>> =
        Observable.combineLatest(
            lastEventSubject,
            callsSubject.map { calls ->
                for (call in calls)
                    if (call.isOnGoing)
                        return@map true
                false
            }) { event, hasCurrentCall -> Pair(event, hasCurrentCall) }

    constructor(accountId: String, contact: Contact) {
        this.accountId = accountId
        contacts = mutableListOf(contact)
        uri = contact.uri
        participant = contact.uri.uri
        mContactSubject.onNext(contacts)
        mMode = BehaviorSubject.createDefault(Mode.Legacy)
    }
    constructor(accountId: String, uri: Uri, mode: Mode) {
        this.accountId = accountId
        this.uri = uri
        contacts = ArrayList(3)
        mMode = BehaviorSubject.createDefault(mode)
    }

    fun getConference(confId: String?): Conference? {
        val id = confId ?: return null
        for (c in currentCalls)
            if (c.id == id || c.getCallById(id) != null)
                return c
        return null
    }

    fun composingStatusChanged(contact: Contact, composing: Account.ComposingStatus) {
        composingStatusSubject.onNext(composing)
    }

    /*val displayName: String?
        get() = contacts[0].displayName*/

    fun addContact(contact: Contact) {
        contacts.add(contact)
        mContactSubject.onNext(contacts)
    }

    fun removeContact(contact: Contact) {
        contacts.remove(contact)
        mContactSubject.onNext(contacts)
    }

    @Synchronized
    fun readMessages(): List<Interaction> {
        val interactions = ArrayList<Interaction>()
        if (isSwarm) {
            if (aggregateHistory.isNotEmpty()) {
                var n = aggregateHistory.size
                do {
                    if (n == 0) break
                    val i = aggregateHistory[--n]
                    if (!i.isRead) {
                        i.read()
                        interactions.add(i)
                        lastRead = i.messageId
                    }
                } while (i.type == Interaction.InteractionType.INVALID)
            }
        } else {
            for (e in rawHistory.descendingMap().values) {
                if (e.type != Interaction.InteractionType.TEXT) continue
                if (e.isRead) {
                    break
                }
                e.read()
                interactions.add(e)
            }
        }
        // Update the last event if it was just read
        interactions.firstOrNull { it.type != Interaction.InteractionType.INVALID }?.let {
            lastEvent = it
        }
        return interactions
    }

    @Synchronized
    fun getMessage(messageId: String): Interaction? = mMessages[messageId]

    fun setLastMessageRead(lastMessageRead: String?) {
        lastRead = lastMessageRead
    }

    fun setLastMessageNotified(lastMessage: String?) {
        lastNotified = lastMessage
    }

    fun stopLoading(): SingleSubject<Conversation>? {
        val ret = mLoadingSubject
        mLoadingSubject = null
        return ret
    }

    fun setProfile(profile: Single<Profile>) {
        profileSubject.onNext(profile)
    }

    fun setMode(mode: Mode) {
        mMode.onNext(mode)
    }

    fun addConference(conference: Conference?) {
        if (conference == null) {
            return
        }
        for (i in currentCalls.indices) {
            val currentConference = currentCalls[i]
            if (currentConference === conference) {
                return
            }
            if (currentConference.id == conference.id) {
                currentCalls[i] = conference
                callsSubject.onNext(currentCalls)
                return
            }
        }
        currentCalls.add(conference)
        callsSubject.onNext(currentCalls)
    }

    fun removeConference(c: Conference) {
        currentCalls.remove(c)
        callsSubject.onNext(currentCalls)
    }

    var isVisible: Boolean
        get() = mVisible
        set(visible) {
            mVisible = visible
            mVisibleSubject.onNext(mVisible)
        }

    /** Flags that notification should not be removed */
    var isBubble = false

    fun getVisible(): Observable<Boolean> = mVisibleSubject

    val contact: Contact?
        get() {
            if (contacts.size == 1) return contacts[0]
            if (isSwarm) {
                check(contacts.size <= 2) { "getContact() called for group conversation of size " + contacts.size }
            }
            for (contact in contacts) {
                if (!contact.isUser) return contact
            }
            return null
        }

    @Synchronized
    fun addCall(call: Call) {
        if (!isSwarm && callHistory.contains(call)) {
            return
        }
        mDirty = true
        aggregateHistory.add(call)
        updatedElementSubject.onNext(Pair(call, ElementStatus.ADD))
    }

    private fun setInteractionProperties(interaction: Interaction) {
        interaction.account = accountId
        if (interaction.contact == null) {
            if (contacts.size == 1) interaction.contact = contacts[0] else {
                if (interaction.author == null) {
                    Log.e(TAG, "Can't set interaction properties: no author for type:" + interaction.type + " id:" + interaction.id + " status:" + interaction.status)
                } else {
                    interaction.contact = findContact(Uri.fromString(interaction.author!!))
                }
            }
        }
    }

    fun findContact(uri: Uri): Contact? = contacts.firstOrNull { it.uri == uri }

    fun addTextMessage(txt: TextMessage) {
        if (mVisible)
            txt.read()
        setInteractionProperties(txt)
        rawHistory[txt.timestamp] = txt
        mDirty = true
        aggregateHistory.add(txt)
        updatedElementSubject.onNext(Pair(txt, ElementStatus.ADD))
    }

    fun addRequestEvent(request: TrustRequest, contact: Contact) {
        if (isSwarm) return
        val event = ContactEvent(accountId, contact, request)
        mDirty = true
        aggregateHistory.add(event)
        updatedElementSubject.onNext(Pair(event, ElementStatus.ADD))
    }

    fun addContactEvent(contact: Contact) {
        addContactEvent(ContactEvent(accountId, contact))
    }

    fun addContactEvent(contactEvent: ContactEvent) {
        mDirty = true
        aggregateHistory.add(contactEvent)
        updatedElementSubject.onNext(Pair(contactEvent, ElementStatus.ADD))
    }

    fun addFileTransfer(dataTransfer: DataTransfer) {
        if (aggregateHistory.contains(dataTransfer)) {
            return
        }
        mDirty = true
        aggregateHistory.add(dataTransfer)
        updatedElementSubject.onNext(Pair(dataTransfer, ElementStatus.ADD))
    }

    private fun isAfter(previous: Interaction, query: Interaction?): Boolean {
        var query = query
        return if (isSwarm) {
            while (query?.parentId != null) {
                if (query.parentId == previous.messageId)
                    return true
                query = mMessages[query.parentId]
            }
            false
        } else {
            previous.timestamp < query!!.timestamp
        }
    }

    @Synchronized
    fun setLastMessageDisplayed(contactId: String, messageId: String) {
        // Check if the new message is after the last displayed message (could be not the case).
        val currentLastMessageDisplayed: Interaction? =
            lastDisplayedMessages[contactId]?.let { getMessage(it) }
        val newPotentialMessageDisplayed = getMessage(messageId)
        val isAfter =
            if (currentLastMessageDisplayed != null && newPotentialMessageDisplayed != null) {
                isAfter(currentLastMessageDisplayed, newPotentialMessageDisplayed)
            } else false

        // Update the last displayed message
        if (newPotentialMessageDisplayed?.type != Interaction.InteractionType.INVALID &&
            newPotentialMessageDisplayed?.type != null &&
            (isAfter || (currentLastMessageDisplayed == null))) {
            lastDisplayedMessages[contactId] = messageId

            updatedElementSubject.onNext(Pair(newPotentialMessageDisplayed, ElementStatus.UPDATE))
            // Also update the previous messages (such as change from sent to displayed)
            var interaction: Interaction? = newPotentialMessageDisplayed
            while (interaction?.messageId != currentLastMessageDisplayed?.messageId
                && interaction != null
                && currentLastMessageDisplayed != null
            ) {
                interaction = mMessages[interaction.parentId]?.apply {
                    updatedElementSubject.onNext(Pair(this, ElementStatus.UPDATE))
                }
            }
        }
    }

    @Synchronized
    fun setLastMessageSent(messageId: String) {
        val currentLastSentMessage: Interaction? = lastSent?.let { getMessage(it) }
        val newPotentialLastSentMessage: Interaction? = getMessage(messageId)
        val isAfter =
                if (currentLastSentMessage != null && newPotentialLastSentMessage != null) {
                    isAfter(currentLastSentMessage, newPotentialLastSentMessage)
                } else false
        if (newPotentialLastSentMessage?.type != Interaction.InteractionType.INVALID &&
                newPotentialLastSentMessage?.type != null &&
                (isAfter || (currentLastSentMessage == null))) {
            lastSent = messageId
        }
    }

    @Synchronized
    fun updateSwarmInteraction(
        messageId: String,
        contactUri: Uri,
        newStatus: Interaction.MessageStates,
    ) {
        val interaction = mMessages[messageId] ?: return
        if (newStatus == Interaction.MessageStates.DISPLAYED) {
            findContact(contactUri)?.let { contact ->
                if (!contact.isUser)
                    setLastMessageDisplayed(contactUri.host, messageId)
            }
        } else if (newStatus != Interaction.MessageStates.SENDING) {
            interaction.status = Interaction.InteractionStatus.SENDING
        }

        if(newStatus == Interaction.MessageStates.SUCCESS) {
            setLastMessageSent(messageId)
        }
        interaction.statusMap = interaction.statusMap.plus(Pair(contactUri.host, newStatus))
        updatedElementSubject.onNext(Pair(interaction, ElementStatus.UPDATE))
    }

    @Synchronized
    fun updateInteraction(element: Interaction) {
        Log.e(TAG, "updateInteraction: ${element.messageId} ${element.status}")
        if (isSwarm) {
            val e = mMessages[element.messageId]
            if (e != null) {
                e.status = element.status
                updatedElementSubject.onNext(Pair(e, ElementStatus.UPDATE))
            } else {
                Log.e(TAG, "Can't find swarm message to update: ${element.messageId}")
            }
        } else {
            setInteractionProperties(element)
            val time = element.timestamp
            val msgs = rawHistory.subMap(time, true, time, true)
            for (txt in msgs.values) {
                if (txt.id == element.id) {
                    txt.status = element.status
                    updatedElementSubject.onNext(Pair(txt, ElementStatus.UPDATE))
                    return
                }
            }
            Log.e(TAG, "Can't find message to update: ${element.id}")
        }
    }

    @Synchronized
    fun sortHistory() {
        if (mDirty) {
            if (!isSwarm) {
                aggregateHistory.sortWith { c1, c2 -> c1.timestamp.compareTo(c2.timestamp) }
            }
            lastEvent = aggregateHistory.lastOrNull { it.type != Interaction.InteractionType.INVALID }
            mDirty = false
        }
    }

    val currentCall: Conference?
        get() = if (currentCalls.isEmpty()) null else currentCalls[0]

    private val callHistory: Collection<Call>
        get() {
            val result: MutableList<Call> = ArrayList()
            for (interaction in aggregateHistory) {
                if (interaction.type == Interaction.InteractionType.CALL) {
                    result.add(interaction as Call)
                }
            }
            return result
        }

    val unreadTextMessages: TreeMap<Long, TextMessage>
        get() {
            val texts = TreeMap<Long, TextMessage>()
            if (isSwarm) {
                synchronized(this) {
                    for (j in aggregateHistory.indices.reversed()) {
                        val i = aggregateHistory[j]
                        if (i !is TextMessage) continue
                        if (i.isRead || i.isNotified) break
                        texts[i.timestamp] = i
                    }
                }
            } else {
                for ((key, value) in rawHistory.descendingMap()) {
                    if (value.type == Interaction.InteractionType.TEXT) {
                        val message = value as TextMessage
                        if (message.isRead || message.isNotified) break
                        texts[key] = message
                    }
                }
            }
            return texts
        }

    private fun findConversationElement(transferId: Int): Interaction? {
        for (interaction in aggregateHistory) {
            if (interaction.type == Interaction.InteractionType.DATA_TRANSFER) {
                if (transferId == interaction.id) {
                    return interaction
                }
            }
        }
        return null
    }

    private fun removeSwarmInteraction(messageId: String): Boolean {
        val i = mMessages.remove(messageId)
        if (i != null) {
            aggregateHistory.remove(i)
            return true
        }
        return false
    }

    private fun removeInteraction(interactionId: Long): Boolean {
        val it = aggregateHistory.iterator()
        while (it.hasNext()) {
            val interaction = it.next()
            if (interactionId == interaction.id.toLong()) {
                it.remove()
                return true
            }
        }
        return false
    }

    /**
     * Clears the conversation cache.
     * @param delete true if you do not want to re-add contact events
     */
    @Synchronized
    fun clearHistory(delete: Boolean) {
        aggregateHistory.clear()
        rawHistory.clear()
        mDirty = false
        if (!delete && !isSwarm && contacts.size == 1)
            aggregateHistory.add(ContactEvent(accountId, contacts[0]))
        clearedSubject.onNext(ArrayList(aggregateHistory))
    }

    @Synchronized
    fun setHistory(loadedConversation: List<Interaction>) {
        mDirty = true
        aggregateHistory.ensureCapacity(loadedConversation.size)
        for (i in loadedConversation) {
            val interaction = getTypedInteraction(i)
            setInteractionProperties(interaction)
            aggregateHistory.add(interaction)
            rawHistory[interaction.timestamp] = interaction
        }
        sortHistory()
    }

    @Synchronized
    fun addElement(interaction: Interaction) {
        setInteractionProperties(interaction)
        when (interaction.type) {
            Interaction.InteractionType.TEXT -> addTextMessage(TextMessage(interaction))
            Interaction.InteractionType.CALL -> addCall(Call(interaction))
            Interaction.InteractionType.CONTACT -> addContactEvent(ContactEvent(interaction))
            Interaction.InteractionType.DATA_TRANSFER -> addFileTransfer(DataTransfer(interaction))
            else -> {}
        }
    }

    /**
     * Adds a swarm interaction to the conversation.
     *
     * @param interaction The interaction to add.
     * @param newMessage  Indicates whether it is a new message.
     */
    @Synchronized
    fun addSwarmElement(interaction: Interaction, newMessage: Boolean) {
        // Handle call interaction
        if (interaction is Call && interaction.confId != null) {
            // interaction.duration is changed when the call is ended.
            // It means duration=0 when the call is started and duration>0 when the call is ended.
            if (interaction.duration != 0L) {
                val startedCall = conferenceStarted.remove(interaction.confId)
                if (startedCall != null) {
                    startedCall.setEnded(interaction)
                    updateInteraction(startedCall)
                }
                else conferenceEnded[interaction.confId!!] = interaction

                val invalidInteraction = // Replacement element
                    Interaction(this, Interaction.InteractionType.INVALID).apply {
                        setSwarmInfo(uri.rawRingId, interaction.messageId!!, interaction.parentId)
                        conversation = this@Conversation
                        contact = interaction.contact
                    }
                addSwarmElement(invalidInteraction, newMessage)
                return
            } else { // Call started but not ended
                val endedCall = conferenceEnded.remove(interaction.confId)
                if (endedCall != null) {
                    interaction.setEnded(endedCall)
                    updateInteraction(endedCall)
                }
                else conferenceStarted[interaction.confId!!] = interaction
            }
        }
        val id = interaction.messageId!!
        val previous = mMessages.put(id, interaction)

        // Update lastDisplayedMessages and lastSent
        interaction.statusMap.entries.forEach {
            if (!findContact(Uri.fromString(it.key))!!.isUser) {
                if (it.value == Interaction.MessageStates.DISPLAYED) {
                    setLastMessageDisplayed(it.key, id)
                }
                if (it.value == Interaction.MessageStates.SUCCESS) {
                    setLastMessageSent(id)
                }
            }
        }

        if (lastRead != null && lastRead == id) interaction.read()
        if (lastNotified != null && lastNotified == id) interaction.isNotified = true
        var newLeaf = false
        var added = false
        if (aggregateHistory.isEmpty() || aggregateHistory.last().messageId == interaction.parentId) {
            // New leaf
            added = true
            newLeaf = true
            aggregateHistory.add(interaction)
            updatedElementSubject.onNext(Pair(interaction, ElementStatus.ADD))
        } else {
            // New root or normal node
            for (i in aggregateHistory.indices) {
                if (id == aggregateHistory[i].parentId) {
                    aggregateHistory.add(i, interaction)
                    updatedElementSubject.onNext(Pair(interaction, ElementStatus.ADD))
                    added = true
                    newLeaf = (i == 0 // True if it is the last non-invalid message.
                            && aggregateHistory.last().type == Interaction.InteractionType.INVALID)
                    break
                }
            }
            if (!added) {
                for (i in aggregateHistory.indices.reversed()) {
                    if (aggregateHistory[i].messageId == interaction.parentId) {
                        added = true
                        newLeaf = true
                        aggregateHistory.add(i + 1, interaction)
                        updatedElementSubject.onNext(Pair(interaction, ElementStatus.ADD))
                        break
                    }
                }
            }
        }
        if (newLeaf) {
            if (isVisible) {
                interaction.read()
                setLastMessageRead(id)
            }
            if (interaction.type != Interaction.InteractionType.INVALID)
                lastEvent = interaction
        }
        if (!added) {
            Log.e(TAG, "Can't attach interaction $id with parent ${interaction.parentId}")
        }
        mPendingMessages.remove(id)?.onSuccess(interaction)
    }

    fun updateFileTransfer(transfer: DataTransfer, eventCode: Interaction.TransferStatus) {
        val dataTransfer = (if (isSwarm) transfer else findConversationElement(transfer.id)) as? DataTransfer
        if (dataTransfer != null) {
            dataTransfer.transferStatus = eventCode
            updatedElementSubject.onNext(Pair(dataTransfer, ElementStatus.UPDATE))
        }
    }

    fun removeInteraction(interaction: Interaction) {
        if (isSwarm) {
            if (removeSwarmInteraction(interaction.messageId!!)) updatedElementSubject.onNext(Pair(interaction, ElementStatus.REMOVE))
        } else {
            if (removeInteraction(interaction.id.toLong())) updatedElementSubject.onNext(Pair(interaction, ElementStatus.REMOVE))
        }
    }

    fun removeAll() {
        aggregateHistory.clear()
        currentCalls.clear()
        rawHistory.clear()
        mDirty = true
    }

    fun setColor(c: Int) {
        color.onNext(c)
    }

    fun setSymbol(s: CharSequence) {
        symbol.onNext(s)
    }

    fun getColor(): Observable<Int> = color

    fun getSymbol(): Observable<CharSequence> = symbol

    fun updatePreferences(preferences: Map<String, String>) {
        val colorValue = preferences[KEY_PREFERENCE_CONVERSATION_COLOR]
        if (colorValue != null) {
            // First, we remove the string first character (the #).
            // The color format is RRGGBB but we want AARRGGBB.
            // So we add FF in front of the color (full opacity).
            color.onNext(colorValue.substring(1).toInt(16) or 0xFF000000.toInt())
        } else color.onNext(0)
        preferences[KEY_PREFERENCE_CONVERSATION_SYMBOL].let {
            symbol.onNext(if (StringUtils.isOnlyEmoji(it)) it!! else "")
        }
    }

    /** Tells if the conversation is a swarm:group with more than 2 participants (including user) */
    fun isGroup() = isSwarm && contacts.size > 2

    /** Tells if the conversation is a swarm:group. No matter how many participants. */
    fun isSwarmGroup() = isSwarm && mode.blockingFirst() != Mode.OneToOne

    @Synchronized
    fun loadMessage(id: String, load: () -> Unit): Single<Interaction> {
        val msg = getMessage(id)
        return if (msg != null) Single.just(msg)
        else mPendingMessages.computeIfAbsent(id) {
            load()
            SingleSubject.create()
        }
    }

    /**
     * Add a reaction in the model.
     * @param reactionInteraction Reaction to add
     * @param reactTo Interaction we are reacting to
     */
    fun addReaction(reactionInteraction: Interaction, reactTo: String) {
        val reactedInteraction = getMessage(reactTo)
        reactedInteraction?.addReaction(reactionInteraction)
    }

    fun removeReaction(reactTo: String, id: String) {
        val interaction = getMessage(reactTo)
        interaction?.removeReaction(id)
    }

    @Synchronized
    fun updateSwarmMessage(interaction: Interaction) {
        val existingInteraction = interaction.messageId?.let { getMessage(it) } ?: return
        interaction.parentId?.let { existingInteraction.updateParent(it) }
        existingInteraction.replaceEdits(interaction.history)
        existingInteraction.replaceReactions(interaction.reactions)
        existingInteraction.body = interaction.body

        if (interaction is DataTransfer && interaction.fileId == "") {
            (existingInteraction as? DataTransfer)?.fileId = interaction.fileId
            existingInteraction.transferStatus = Interaction.TransferStatus.FILE_REMOVED
        }

        updatedElementSubject.onNext(Pair(existingInteraction, ElementStatus.UPDATE))
        if (lastEvent == existingInteraction) {
            lastEventSubject.onNext(existingInteraction)
        }
    }

    data class ActiveCall(val confId: String, val uri: String, val device: String) {
        constructor(map: Map<String, String>) :
                this(map[KEY_CONF_ID]!!, map[KEY_URI]!!, map[KEY_DEVICE]!!)

        companion object {
            const val KEY_CONF_ID = "id"
            const val KEY_URI = "uri"
            const val KEY_DEVICE = "device"
        }
    }

    fun setActiveCalls(activeCalls: List<ActiveCall>) =
        activeCallsSubject.onNext(activeCalls)

    private val conferenceStarted: MutableMap<String, Call> = HashMap()
    private val conferenceEnded: MutableMap<String, Call> = HashMap()

    enum class Mode {
        OneToOne, AdminInvitesOnly, InvitesOnly,  // Non-daemon modes
        Syncing, Public, Legacy, Request;

        val isSwarm: Boolean
            get() = this == OneToOne || this == InvitesOnly || this == Public
    }

    interface ConversationActionCallback {
        fun removeConversation(accountId: String, conversationUri: Uri)
        fun clearConversation(accountId: String, conversationUri: Uri)
        fun copyContactNumberToClipboard(contactNumber: String)
    }

    companion object {
        private val TAG = Conversation::class.simpleName!!
        const val KEY_PREFERENCE_CONVERSATION_COLOR = "color"
        const val KEY_PREFERENCE_CONVERSATION_SYMBOL = "symbol"

        private fun getTypedInteraction(interaction: Interaction) = when (interaction.type) {
            Interaction.InteractionType.TEXT -> TextMessage(interaction)
            Interaction.InteractionType.CALL -> Call(interaction)
            Interaction.InteractionType.CONTACT -> ContactEvent(interaction)
            Interaction.InteractionType.DATA_TRANSFER -> DataTransfer(interaction)
            else -> interaction
        }
    }
}