diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactList.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactList.kt
index 7fa969a5b36d8be169920ee6e3b99c961f6c1ee7..06913697cf7cf93bbf2f75173a4bd20b0883fbcc 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactList.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactList.kt
@@ -48,8 +48,8 @@ fun ContactList(
                 items(viewModel.contactList) { contactItem ->
                     ContactCard(
                         contactItem,
-                        { viewModel.selectContact(contactItem.contact) },
-                        viewModel.isSelected(contactItem.contact)
+                        { viewModel.selectContact(contactItem.contact.id) },
+                        viewModel.isSelected(contactItem.contact.id)
                     )
                 }
             }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt
index ee4a7f8c82a3cb0c8a86c53c409169e8f54193bb..a7769fe4a7d25a89579a01c76844b4215dc8d82b 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt
@@ -4,12 +4,14 @@ import androidx.compose.runtime.State
 import androidx.compose.runtime.mutableStateOf
 import org.briarproject.bramble.api.connection.ConnectionRegistry
 import org.briarproject.bramble.api.contact.Contact
+import org.briarproject.bramble.api.contact.ContactId
 import org.briarproject.bramble.api.contact.ContactManager
 import org.briarproject.bramble.api.contact.event.ContactAliasChangedEvent
 import org.briarproject.bramble.api.event.Event
 import org.briarproject.bramble.api.event.EventBus
 import org.briarproject.briar.api.conversation.ConversationManager
 import org.briarproject.briar.api.conversation.event.ConversationMessageReceivedEvent
+import org.briarproject.briar.desktop.conversation.ConversationMessageToBeSentEvent
 import java.util.logging.Logger
 import javax.inject.Inject
 
@@ -32,16 +34,16 @@ constructor(
     }
 
     private val _filterBy = mutableStateOf("")
-    private val _selectedContact = mutableStateOf<Contact?>(null)
+    private val _selectedContactId = mutableStateOf<ContactId?>(null)
 
     val filterBy: State<String> = _filterBy
-    val selectedContact: State<Contact?> = _selectedContact
+    val selectedContactId: State<ContactId?> = _selectedContactId
 
-    fun selectContact(contact: Contact) {
-        _selectedContact.value = contact
+    fun selectContact(contactId: ContactId) {
+        _selectedContactId.value = contactId
     }
 
-    fun isSelected(contact: Contact) = _selectedContact.value?.id == contact.id
+    fun isSelected(contactId: ContactId) = _selectedContactId.value == contactId
 
     override fun filterContact(contact: Contact) =
         // todo: also filter on alias
@@ -54,7 +56,11 @@ constructor(
 
     override fun updateFilteredList() {
         super.updateFilteredList()
-        _selectedContact.value?.let { if (!filterContact(it)) _selectedContact.value = null }
+
+        _selectedContactId.value?.let { id ->
+            if (!contactList.map { it.contact.id }.contains(id))
+                _selectedContactId.value = null
+        }
     }
 
     override fun eventOccurred(e: Event?) {
@@ -64,6 +70,10 @@ constructor(
                 LOG.info("Conversation message received, updating item")
                 updateItem(e.contactId) { it.updateFromMessageHeader(e.messageHeader) }
             }
+            is ConversationMessageToBeSentEvent -> {
+                LOG.info("Conversation message added, updating item")
+                updateItem(e.contactId) { it.updateFromMessageHeader(e.messageHeader) }
+            }
             // is AvatarUpdatedEvent -> {}
             is ContactAliasChangedEvent -> {
                 updateItem(e.contactId) { it.updateAlias(e.alias) }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/Chat.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/Chat.kt
deleted file mode 100644
index 9735b9968873dc9dfeae814371f82bfb4d19562e..0000000000000000000000000000000000000000
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/Chat.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package org.briarproject.briar.desktop.conversation
-
-import org.briarproject.briar.desktop.utils.TimeUtils.getFormattedTimestamp
-
-class Chat {
-
-    val messages: MutableList<SimpleMessage> = ArrayList()
-
-    fun appendMessage(local: Boolean, timestamp: Long, messageText: String?) {
-        val author = if (local) "You" else "Other"
-        val formattedTimestamp = getFormattedTimestamp(timestamp)
-        messages.add(SimpleMessage(local, author, messageText!!, formattedTimestamp, true))
-    }
-}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ChatHistoryConversationVisitor.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ChatHistoryConversationVisitor.kt
deleted file mode 100644
index 4163d16c74135220a6766664988888d33e91ef2f..0000000000000000000000000000000000000000
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ChatHistoryConversationVisitor.kt
+++ /dev/null
@@ -1,101 +0,0 @@
-package org.briarproject.briar.desktop.conversation
-
-import org.briarproject.bramble.api.db.DbException
-import org.briarproject.bramble.util.LogUtils
-import org.briarproject.briar.api.blog.BlogInvitationRequest
-import org.briarproject.briar.api.blog.BlogInvitationResponse
-import org.briarproject.briar.api.conversation.ConversationMessageHeader
-import org.briarproject.briar.api.conversation.ConversationMessageVisitor
-import org.briarproject.briar.api.forum.ForumInvitationRequest
-import org.briarproject.briar.api.forum.ForumInvitationResponse
-import org.briarproject.briar.api.introduction.IntroductionRequest
-import org.briarproject.briar.api.introduction.IntroductionResponse
-import org.briarproject.briar.api.messaging.MessagingManager
-import org.briarproject.briar.api.messaging.PrivateMessageHeader
-import org.briarproject.briar.api.privategroup.invitation.GroupInvitationRequest
-import org.briarproject.briar.api.privategroup.invitation.GroupInvitationResponse
-import java.util.logging.Level.WARNING
-import java.util.logging.Logger
-
-class ChatHistoryConversationVisitor(
-    private val chat: Chat,
-    private val messagingManager: MessagingManager
-) :
-    ConversationMessageVisitor<Void?> {
-
-    companion object {
-        private val LOG = Logger.getLogger(ChatHistoryConversationVisitor::class.java.name)
-    }
-
-    private fun appendMessage(header: ConversationMessageHeader) {
-        try {
-            val messageText = messagingManager.getMessageText(header.id)
-            chat.appendMessage(header.isLocal, header.timestamp, messageText)
-        } catch (e: DbException) {
-            LOG.warning("Error while getting message text")
-            LogUtils.logException(LOG, WARNING, e)
-        }
-    }
-
-    override fun visitPrivateMessageHeader(h: PrivateMessageHeader): Void? {
-        appendMessage(h)
-        return null
-    }
-
-    override fun visitBlogInvitationRequest(r: BlogInvitationRequest): Void? {
-        return null
-    }
-
-    override fun visitBlogInvitationResponse(r: BlogInvitationResponse): Void? {
-        return null
-    }
-
-    override fun visitForumInvitationRequest(r: ForumInvitationRequest): Void? {
-        return null
-    }
-
-    override fun visitForumInvitationResponse(r: ForumInvitationResponse): Void? {
-        return null
-    }
-
-    override fun visitGroupInvitationRequest(r: GroupInvitationRequest): Void? {
-        return null
-    }
-
-    override fun visitGroupInvitationResponse(r: GroupInvitationResponse): Void? {
-        return null
-    }
-
-    override fun visitIntroductionRequest(r: IntroductionRequest): Void? {
-        chat.appendMessage(
-            r.isLocal, r.timestamp,
-            String.format(
-                "You received an introduction request! Username: %s, Message: %s",
-                r.name, r.text
-            )
-        )
-        if (!r.wasAnswered()) {
-            chat.appendMessage(
-                r.isLocal, r.timestamp,
-                "Do you accept the invitation?"
-            )
-            // TODO chat.appendYesNoButtons(r);
-        }
-        return null
-    }
-
-    override fun visitIntroductionResponse(r: IntroductionResponse): Void? {
-        if (r.wasAccepted()) {
-            chat.appendMessage(
-                r.isLocal, r.timestamp,
-                "You accepted the request"
-            )
-        } else {
-            chat.appendMessage(
-                r.isLocal, r.timestamp,
-                "You declined the request"
-            )
-        }
-        return null
-    }
-}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ChatState.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ChatState.kt
deleted file mode 100644
index e996fd151858c8b9898d499d584aeadfa9a7cf71..0000000000000000000000000000000000000000
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ChatState.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package org.briarproject.briar.desktop.conversation
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import org.briarproject.bramble.api.contact.ContactId
-import org.briarproject.briar.api.conversation.ConversationMessageHeader
-import org.briarproject.briar.desktop.ui.CVM
-import org.briarproject.briar.desktop.ui.MM
-import org.briarproject.briar.desktop.ui.UiState
-import java.util.Collections
-
-@Composable
-fun ChatState(id: ContactId): MutableState<UiState<Chat>> {
-    val state: MutableState<UiState<Chat>> = remember { mutableStateOf(UiState.Loading) }
-    val messagingManager = MM.current
-    val conversationManager = CVM.current
-
-    DisposableEffect(id) {
-        state.value = UiState.Loading
-        val chat = Chat()
-        val visitor = ChatHistoryConversationVisitor(chat, messagingManager)
-        val messageHeaders: List<ConversationMessageHeader> = ArrayList(conversationManager.getMessageHeaders(id))
-        Collections.sort(messageHeaders, ConversationMessageHeaderComparator())
-        // Reverse order here because we're using reverseLayout=true on the LazyColumn to display items
-        // from bottom to top
-        Collections.reverse(messageHeaders)
-        for (header in messageHeaders) {
-            header.accept(visitor)
-        }
-        state.value = UiState.Success(chat)
-        onDispose { }
-    }
-    return state
-}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/Conversation.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/Conversation.kt
index 23c61666c1115c531806d12bec299ece7df21be9..dee4283f89c6ef0877e195600cb87168b5b0abda 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/Conversation.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/Conversation.kt
@@ -4,38 +4,56 @@ import androidx.compose.animation.core.animateDpAsState
 import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.BoxWithConstraints
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.requiredSize
 import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.MaterialTheme
 import androidx.compose.material.Scaffold
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.unit.dp
-import org.briarproject.bramble.api.contact.Contact
+import org.briarproject.bramble.api.contact.ContactId
 import org.briarproject.briar.desktop.contact.ContactInfoDrawer
 import org.briarproject.briar.desktop.contact.ContactInfoDrawerState
 import org.briarproject.briar.desktop.introduction.IntroductionViewModel
 import org.briarproject.briar.desktop.navigation.SIDEBAR_WIDTH
 import org.briarproject.briar.desktop.theme.surfaceVariant
 import org.briarproject.briar.desktop.ui.Constants.CONTACTLIST_WIDTH
+import org.briarproject.briar.desktop.ui.Loader
 
 @Composable
 fun Conversation(
-    contact: Contact,
+    contactId: ContactId,
+    conversationViewModel: ConversationViewModel,
     introductionViewModel: IntroductionViewModel,
 ) {
+    LaunchedEffect(contactId) {
+        conversationViewModel.setContactId(contactId)
+    }
+
+    val contactItem = conversationViewModel.contactItem.value
+
+    if (contactItem == null) {
+        Loader()
+        return
+    }
+
     val (infoDrawer, setInfoDrawer) = remember { mutableStateOf(false) }
     val (contactDrawerState, setDrawerState) = remember { mutableStateOf(ContactInfoDrawerState.MakeIntro) }
     BoxWithConstraints(Modifier.fillMaxSize()) {
@@ -43,10 +61,10 @@ fun Conversation(
         Scaffold(
             topBar = {
                 ConversationHeader(
-                    contact,
+                    contactItem,
                     onMakeIntroduction = {
                         introductionViewModel.apply {
-                            setFirstContact(contact)
+                            setFirstContact(contactItem.contact)
                             loadContacts()
                         }
                         setInfoDrawer(true)
@@ -54,12 +72,26 @@ fun Conversation(
                 )
             },
             content = { padding ->
-                Box(modifier = Modifier.padding(padding)) {
-                    val chat = ChatState(contact.id)
-                    TextBubbles(chat.value)
+                LazyColumn(
+                    verticalArrangement = Arrangement.spacedBy(8.dp),
+                    // reverseLayout to display most recent message (index 0) at the bottom
+                    reverseLayout = true,
+                    contentPadding = PaddingValues(8.dp),
+                    modifier = Modifier.padding(padding).fillMaxHeight()
+                ) {
+                    items(conversationViewModel.messages) { m ->
+                        if (m is ConversationMessageItem)
+                            TextBubble(m)
+                    }
                 }
             },
-            bottomBar = { ConversationInput() },
+            bottomBar = {
+                ConversationInput(
+                    conversationViewModel.newMessage.value,
+                    conversationViewModel::setNewMessage,
+                    conversationViewModel::sendMessage
+                )
+            },
         )
         if (infoDrawer) {
             // TODO Find non-hacky way of setting scrim on entire app
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationHeader.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationHeader.kt
index a36dca368671dfc5302184dab43c84e45e50a6a5..7a92663a4f2275af8d431a605b3b40cc2d474501 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationHeader.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationHeader.kt
@@ -21,26 +21,27 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.drawscope.withTransform
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
-import org.briarproject.bramble.api.contact.Contact
 import org.briarproject.briar.desktop.contact.ContactDropDown
+import org.briarproject.briar.desktop.contact.ContactItem
 import org.briarproject.briar.desktop.contact.ProfileCircle
 import org.briarproject.briar.desktop.theme.outline
+import org.briarproject.briar.desktop.theme.surfaceVariant
 import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE
 import org.briarproject.briar.desktop.ui.HorizontalDivider
 
 @Composable
 fun ConversationHeader(
-    contact: Contact,
+    contactItem: ContactItem,
     onMakeIntroduction: () -> Unit,
 ) {
     val (isExpanded, setExpanded) = remember { mutableStateOf(false) }
-    // TODO hook up online indicator logic
-    val onlineColor = MaterialTheme.colors.secondary
+    val onlineColor =
+        if (contactItem.isConnected) MaterialTheme.colors.secondary else MaterialTheme.colors.surfaceVariant
     val outlineColor = MaterialTheme.colors.outline
 
     Box(modifier = Modifier.fillMaxWidth().height(HEADER_SIZE + 1.dp)) {
         Row(modifier = Modifier.align(Alignment.Center)) {
-            ProfileCircle(36.dp, contact.author.id.bytes)
+            ProfileCircle(36.dp, contactItem.contact.author.id.bytes)
             Canvas(
                 modifier = Modifier.align(Alignment.CenterVertically),
                 onDraw = {
@@ -52,7 +53,7 @@ fun ConversationHeader(
                 }
             )
             Text(
-                contact.author.name,
+                contactItem.contact.author.name,
                 modifier = Modifier.align(Alignment.CenterVertically).padding(start = 12.dp),
                 fontSize = 20.sp
             )
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationInput.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationInput.kt
index 54eef33b668b01b81873976936c8f7bd2c9dbd36..dcfa6d755ff7af6a179a2a4975f0c68b575e0d30 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationInput.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationInput.kt
@@ -19,10 +19,6 @@ import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Send
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.text.TextStyle
@@ -36,19 +32,18 @@ import org.briarproject.briar.desktop.ui.HorizontalDivider
 fun PreviewConversationInput() {
     MaterialTheme(colors = DarkColors) {
         Surface {
-            ConversationInput()
+            ConversationInput("Lorem ipsum.", {}, {})
         }
     }
 }
 
 @Composable
-fun ConversationInput() {
-    var text by remember { mutableStateOf("") }
+fun ConversationInput(text: String, updateText: (String) -> Unit, onSend: () -> Unit) {
     Column {
         HorizontalDivider()
         TextField(
             value = text,
-            onValueChange = { text = it },
+            onValueChange = updateText,
             maxLines = 10,
             textStyle = TextStyle(fontSize = 16.sp, lineHeight = 16.sp),
             placeholder = { Text("Message") },
@@ -70,7 +65,7 @@ fun ConversationInput() {
             },
             trailingIcon = {
                 IconButton(
-                    onClick = { }, modifier = Modifier.padding(4.dp).size(32.dp),
+                    onClick = onSend, modifier = Modifier.padding(4.dp).size(32.dp),
                 ) {
                     Icon(
                         Icons.Filled.Send,
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationItem.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationItem.kt
new file mode 100644
index 0000000000000000000000000000000000000000..bc7b2959e2427b8dcd169ad2ce2b4a926ed2eec8
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationItem.kt
@@ -0,0 +1,31 @@
+package org.briarproject.briar.desktop.conversation
+
+import org.briarproject.bramble.api.sync.GroupId
+import org.briarproject.bramble.api.sync.MessageId
+
+sealed class ConversationItem {
+    abstract val id: MessageId
+    abstract val groupId: GroupId
+    abstract val time: Long
+    abstract val autoDeleteTimer: Long
+    abstract val isIncoming: Boolean
+
+    /**
+     * Only useful for incoming messages.
+     */
+    abstract val isRead: Boolean
+
+    /**
+     * Only useful for outgoing messages.
+     */
+    abstract val isSent: Boolean
+
+    /**
+     * Only useful for outgoing messages.
+     */
+    abstract val isSeen: Boolean
+
+    abstract fun mark(sent: Boolean, seen: Boolean): ConversationItem
+
+    abstract fun markRead(): ConversationItem
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageHeaderComparator.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageHeaderComparator.kt
deleted file mode 100644
index 1579924735705b1baa24002a27dae53ea947595a..0000000000000000000000000000000000000000
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageHeaderComparator.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package org.briarproject.briar.desktop.conversation
-
-import org.briarproject.briar.api.conversation.ConversationMessageHeader
-
-class ConversationMessageHeaderComparator : Comparator<ConversationMessageHeader> {
-
-    override fun compare(
-        h1: ConversationMessageHeader,
-        h2: ConversationMessageHeader
-    ): Int {
-        return h1.timestamp.compareTo(h2.timestamp)
-    }
-}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageItem.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageItem.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2d48f76bb69eae1a3b8a75ab7b8683008c927436
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageItem.kt
@@ -0,0 +1,41 @@
+package org.briarproject.briar.desktop.conversation
+
+import org.briarproject.bramble.api.sync.GroupId
+import org.briarproject.bramble.api.sync.MessageId
+import org.briarproject.briar.api.conversation.ConversationMessageHeader
+
+data class ConversationMessageItem(
+    var text: String? = null,
+    override val id: MessageId,
+    override val groupId: GroupId,
+    override val time: Long,
+    override val autoDeleteTimer: Long,
+    override val isIncoming: Boolean,
+    override var isRead: Boolean,
+    override var isSent: Boolean,
+    override var isSeen: Boolean,
+
+    // todo: support attachments
+    // val attachments: List<AttachmentItem>
+) : ConversationItem() {
+
+    constructor(h: ConversationMessageHeader) :
+        this(
+            id = h.id,
+            groupId = h.groupId,
+            time = h.timestamp,
+            autoDeleteTimer = h.autoDeleteTimer,
+            isRead = h.isRead,
+            isSent = h.isSent,
+            isSeen = h.isSeen,
+            isIncoming = !h.isLocal,
+        )
+
+    override fun mark(sent: Boolean, seen: Boolean): ConversationItem {
+        return copy(isSent = sent, isSeen = seen)
+    }
+
+    override fun markRead(): ConversationItem {
+        return copy(isRead = true)
+    }
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageToBeSentEvent.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageToBeSentEvent.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e79b0362858cb663877bbf919806ae176372f403
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageToBeSentEvent.kt
@@ -0,0 +1,13 @@
+package org.briarproject.briar.desktop.conversation
+
+import org.briarproject.bramble.api.contact.ContactId
+import org.briarproject.bramble.api.event.Event
+import org.briarproject.briar.api.conversation.ConversationMessageHeader
+
+/**
+ * An event that is broadcast when a new conversation message to be sent is added.
+ */
+data class ConversationMessageToBeSentEvent(
+    val messageHeader: ConversationMessageHeader,
+    val contactId: ContactId
+) : Event()
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8d9902e240f3f07a51a1c550a185055f42704e25
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt
@@ -0,0 +1,234 @@
+package org.briarproject.briar.desktop.conversation
+
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import org.briarproject.bramble.api.FormatException
+import org.briarproject.bramble.api.connection.ConnectionRegistry
+import org.briarproject.bramble.api.contact.ContactId
+import org.briarproject.bramble.api.contact.ContactManager
+import org.briarproject.bramble.api.contact.event.ContactRemovedEvent
+import org.briarproject.bramble.api.db.DbException
+import org.briarproject.bramble.api.db.NoSuchContactException
+import org.briarproject.bramble.api.event.Event
+import org.briarproject.bramble.api.event.EventBus
+import org.briarproject.bramble.api.event.EventListener
+import org.briarproject.bramble.api.plugin.event.ContactConnectedEvent
+import org.briarproject.bramble.api.plugin.event.ContactDisconnectedEvent
+import org.briarproject.bramble.api.sync.MessageId
+import org.briarproject.bramble.api.sync.event.MessagesAckedEvent
+import org.briarproject.bramble.api.sync.event.MessagesSentEvent
+import org.briarproject.bramble.api.versioning.event.ClientVersionUpdatedEvent
+import org.briarproject.bramble.util.LogUtils
+import org.briarproject.briar.api.autodelete.UnexpectedTimerException
+import org.briarproject.briar.api.autodelete.event.ConversationMessagesDeletedEvent
+import org.briarproject.briar.api.conversation.ConversationManager
+import org.briarproject.briar.api.conversation.event.ConversationMessageReceivedEvent
+import org.briarproject.briar.api.messaging.MessagingManager
+import org.briarproject.briar.api.messaging.PrivateMessage
+import org.briarproject.briar.api.messaging.PrivateMessageFactory
+import org.briarproject.briar.api.messaging.PrivateMessageHeader
+import org.briarproject.briar.desktop.contact.ContactItem
+import org.briarproject.briar.desktop.utils.replaceIf
+import java.util.Date
+import java.util.logging.Level
+import java.util.logging.Logger
+import javax.inject.Inject
+
+class ConversationViewModel
+@Inject
+constructor(
+    private val connectionRegistry: ConnectionRegistry,
+    private val contactManager: ContactManager,
+    private val conversationManager: ConversationManager,
+    private val messagingManager: MessagingManager,
+    private val eventBus: EventBus,
+    private val privateMessageFactory: PrivateMessageFactory,
+) : EventListener {
+
+    companion object {
+        private val LOG = Logger.getLogger(ConversationViewModel::class.java.name)
+    }
+
+    init {
+        // todo: where/when to remove listener again?
+        eventBus.addListener(this)
+    }
+
+    private val _contactId = mutableStateOf<ContactId?>(null)
+    private val _contactItem = mutableStateOf<ContactItem?>(null)
+    private val _messages = mutableStateListOf<ConversationItem>()
+
+    private val _newMessage = mutableStateOf("")
+
+    val contactItem: State<ContactItem?> = _contactItem
+    val messages: List<ConversationItem> = _messages
+
+    val newMessage: State<String> = _newMessage
+
+    fun setContactId(id: ContactId) {
+        _contactId.value = id
+        _contactItem.value = ContactItem(
+            contactManager.getContact(id),
+            connectionRegistry.isConnected(id),
+            conversationManager.getGroupCount(id),
+        )
+        loadMessages()
+    }
+
+    fun setNewMessage(msg: String) {
+        _newMessage.value = msg
+    }
+
+    fun sendMessage() {
+        try {
+            val text = _newMessage.value
+            _newMessage.value = ""
+
+            val start = LogUtils.now()
+            val m = createMessage(text)
+            messagingManager.addLocalMessage(m)
+            LogUtils.logDuration(LOG, "Storing message", start)
+
+            val message = m.message
+            val h = PrivateMessageHeader(
+                message.id, message.groupId,
+                message.timestamp, true, true, false, false,
+                m.hasText(), m.attachmentHeaders,
+                m.autoDeleteTimer
+            )
+            _messages.add(0, messageHeaderToItem(h))
+            eventBus.broadcast(ConversationMessageToBeSentEvent(h, _contactId.value!!))
+        } catch (e: UnexpectedTimerException) {
+            LogUtils.logException(LOG, Level.WARNING, e)
+        } catch (e: DbException) {
+            LogUtils.logException(LOG, Level.WARNING, e)
+        }
+    }
+
+    @Throws(DbException::class)
+    private fun createMessage(text: String): PrivateMessage {
+        val groupId = messagingManager.getContactGroup(_contactItem.value!!.contact).id
+        // todo: this API call needs a database transaction context
+        // val timestamp = conversationManager.getTimestampForOutgoingMessage(_contactId.value!!)
+        val timestamp = Date().time
+        try {
+            return privateMessageFactory.createLegacyPrivateMessage(
+                groupId, timestamp, text
+            )
+        } catch (e: FormatException) {
+            throw AssertionError(e)
+        }
+    }
+
+    private fun loadMessages() {
+        try {
+            val start = LogUtils.now()
+            val headers = conversationManager.getMessageHeaders(_contactId.value!!)
+            LogUtils.logDuration(LOG, "Loading messages", start)
+            // Sort headers by timestamp in *descending* order
+            val sorted = headers.sortedByDescending { it.timestamp }
+            _messages.apply {
+                clear()
+                addAll(
+                    // todo: use ConversationVisitor to also display Request and Notice Messages
+                    sorted.filterIsInstance<PrivateMessageHeader>().map(::messageHeaderToItem)
+                )
+            }
+        } catch (e: NoSuchContactException) {
+            // finishOnUiThread()
+        } catch (e: DbException) {
+            LogUtils.logException(LOG, Level.WARNING, e)
+        }
+    }
+
+    private fun messageHeaderToItem(h: PrivateMessageHeader): ConversationMessageItem {
+        // todo: use ConversationVisitor instead and support other MessageHeader
+        val item = ConversationMessageItem(h)
+        if (h.hasText()) {
+            item.text = loadMessageText(h.id)
+        }
+        return item
+    }
+
+    private fun loadMessageText(m: MessageId): String? {
+        try {
+            val start = LogUtils.now()
+            val text = messagingManager.getMessageText(m)
+            LogUtils.logDuration(LOG, "Loading text", start)
+
+            return text
+        } catch (e: DbException) {
+            LogUtils.logException(LOG, Level.WARNING, e)
+        }
+        return null
+    }
+
+    override fun eventOccurred(e: Event?) {
+        when (e) {
+            is ContactRemovedEvent -> {
+                if (e.contactId == _contactId.value) {
+                    LOG.info("Contact removed")
+                    // todo: we probably don't need to react to this here as the ContactsViewModel should already handle it
+                }
+            }
+            is ConversationMessageReceivedEvent<*> -> {
+                if (e.contactId == _contactId.value) {
+                    LOG.info("Message received, adding")
+                    val h = e.messageHeader
+                    if (h is PrivateMessageHeader) {
+                        // insert at start of list according to descending sort order
+                        _messages.add(0, messageHeaderToItem(h))
+                    }
+                }
+            }
+            is MessagesSentEvent -> {
+                if (e.contactId == _contactId.value) {
+                    LOG.info("Messages sent")
+                    markMessages(e.messageIds, sent = true, seen = false)
+                }
+            }
+            is MessagesAckedEvent -> {
+                if (e.contactId == _contactId.value) {
+                    LOG.info("Messages acked")
+                    markMessages(e.messageIds, sent = true, seen = true)
+                }
+            }
+            is ConversationMessagesDeletedEvent -> {
+                if (e.contactId == _contactId.value) {
+                    LOG.info("Messages auto-deleted")
+                    val messages = HashSet(e.messageIds)
+                    _messages.removeIf { messages.contains(it.id) }
+                }
+            }
+            is ContactConnectedEvent -> {
+                if (e.contactId == _contactId.value) {
+                    LOG.info("Contact connected")
+                    _contactItem.value = _contactItem.value!!.updateIsConnected(true)
+                }
+            }
+            is ContactDisconnectedEvent -> {
+                if (e.contactId == _contactId.value) {
+                    LOG.info("Contact disconnected")
+                    _contactItem.value = _contactItem.value!!.updateIsConnected(false)
+                }
+            }
+            is ClientVersionUpdatedEvent -> {
+                if (e.contactId == _contactId.value) {
+                    // todo: still not implemented
+                }
+            }
+        }
+    }
+
+    private fun markMessages(
+        messageIds: Collection<MessageId>,
+        sent: Boolean,
+        seen: Boolean
+    ) {
+        val messages = HashSet(messageIds)
+        _messages.replaceIf({ !it.isIncoming && messages.contains(it.id) }) {
+            it.mark(sent, seen)
+        }
+    }
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageView.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageView.kt
index b9dc7644181c81c97321295ee10b97a739fd32d4..2ce3ccbbde31424da9bec60638c4a965428811bf 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageView.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageView.kt
@@ -16,6 +16,7 @@ import org.briarproject.briar.desktop.ui.VerticalDivider
 @Composable
 fun PrivateMessageView(
     contactListViewModel: ContactListViewModel,
+    conversationViewModel: ConversationViewModel,
     addContactViewModel: AddContactViewModel,
     introductionViewModel: IntroductionViewModel,
 ) {
@@ -23,9 +24,10 @@ fun PrivateMessageView(
         ContactList(contactListViewModel, addContactViewModel)
         VerticalDivider()
         Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
-            contactListViewModel.selectedContact.value?.also { selectedContact ->
+            contactListViewModel.selectedContactId.value?.also { contactId ->
                 Conversation(
-                    selectedContact,
+                    contactId,
+                    conversationViewModel,
                     introductionViewModel
                 )
             } ?: UiPlaceholder()
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/SimpleMessage.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/SimpleMessage.kt
deleted file mode 100644
index 686f1e53151c5bd51b46633ee8fc5df58e97f524..0000000000000000000000000000000000000000
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/SimpleMessage.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package org.briarproject.briar.desktop.conversation
-
-data class SimpleMessage(
-    val local: Boolean,
-    val from: String?,
-    val message: String,
-    val time: String,
-    val delivered: Boolean,
-    // val read: Boolean,
-)
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/TextBubble.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/TextBubble.kt
index 543f06ad956b7498b400234da970520bfbadeb63..33f8a365d44d1cedd724d926bc9eb30405deebad 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/TextBubble.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/TextBubble.kt
@@ -11,53 +11,41 @@ import androidx.compose.material.Icon
 import androidx.compose.material.MaterialTheme
 import androidx.compose.material.Text
 import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Done
 import androidx.compose.material.icons.filled.DoneAll
 import androidx.compose.material.icons.filled.Schedule
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import org.briarproject.briar.desktop.theme.awayMsgBubble
 import org.briarproject.briar.desktop.theme.localMsgBubble
+import org.briarproject.briar.desktop.utils.TimeUtils
 
 @Composable
-fun TextBubble(m: SimpleMessage) {
-    if (m.local) {
-        TextBubble(
-            m,
-            Alignment.End,
-            MaterialTheme.colors.localMsgBubble,
-            RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp, bottomStart = 10.dp)
-        )
-    } else {
-        TextBubble(
-            m,
-            Alignment.Start,
-            MaterialTheme.colors.awayMsgBubble,
-            RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp, bottomEnd = 10.dp)
-        )
-    }
-}
+fun TextBubble(m: ConversationMessageItem) {
+    val alignment = if (m.isIncoming) Alignment.Start else Alignment.End
+    val color = if (m.isIncoming) MaterialTheme.colors.awayMsgBubble else MaterialTheme.colors.localMsgBubble
+    val shape = if (m.isIncoming)
+        RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp, bottomEnd = 10.dp)
+    else
+        RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp, bottomStart = 10.dp)
 
-@Composable
-fun TextBubble(m: SimpleMessage, alignment: Alignment.Horizontal, color: Color, shape: RoundedCornerShape) {
     Column(Modifier.fillMaxWidth()) {
         Column(Modifier.fillMaxWidth(fraction = 0.8f).align(alignment)) {
             Card(Modifier.align(alignment), backgroundColor = color, shape = shape) {
                 Column(
                     Modifier.padding(8.dp)
                 ) {
-                    Text(m.message, fontSize = 14.sp, modifier = Modifier.align(Alignment.Start))
+                    Text(m.text!!, fontSize = 14.sp, modifier = Modifier.align(Alignment.Start))
                     Row(modifier = Modifier.padding(top = 4.dp)) {
-                        Text(m.time, Modifier.padding(end = 4.dp), fontSize = 10.sp)
-                        if (m.delivered) {
-                            val modifier = Modifier.size(12.dp).align(Alignment.CenterVertically)
-                            Icon(Icons.Filled.DoneAll, "sent", modifier)
-                        } else {
+                        Text(TimeUtils.getFormattedTimestamp(m.time), Modifier.padding(end = 4.dp), fontSize = 10.sp)
+                        if (!m.isIncoming) {
                             val modifier = Modifier.size(12.dp).align(Alignment.CenterVertically)
-                            Icon(Icons.Filled.Schedule, "sending", modifier)
+                            val icon =
+                                if (m.isSeen) Icons.Filled.DoneAll else if (m.isSent) Icons.Filled.Done else Icons.Filled.Schedule
+                            Icon(icon, "sent", modifier)
                         }
                     }
                 }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/TextBubbles.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/TextBubbles.kt
deleted file mode 100644
index 8d3f73fc5e310b8149294e089d8152db8e81939b..0000000000000000000000000000000000000000
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/TextBubbles.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package org.briarproject.briar.desktop.conversation
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.unit.dp
-import org.briarproject.briar.desktop.ui.Loader
-import org.briarproject.briar.desktop.ui.UiState
-
-@Composable
-fun TextBubbles(chat: UiState<Chat>) {
-    when (chat) {
-        is UiState.Loading -> Loader()
-        is UiState.Error -> Loader()
-        is UiState.Success ->
-            LazyColumn(
-                verticalArrangement = Arrangement.spacedBy(8.dp),
-                reverseLayout = true,
-                contentPadding = PaddingValues(8.dp)
-            ) {
-                items(chat.data.messages) { m -> TextBubble(m) }
-            }
-    }
-}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt b/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt
index f621dab9eb5a47577d6700d6010fb7fc320d56d2..d28cf156198dd2b55b346e8a91c35717a50d45dd 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt
@@ -17,6 +17,7 @@ import org.briarproject.briar.api.conversation.ConversationManager
 import org.briarproject.briar.api.messaging.MessagingManager
 import org.briarproject.briar.desktop.contact.ContactListViewModel
 import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel
+import org.briarproject.briar.desktop.conversation.ConversationViewModel
 import org.briarproject.briar.desktop.introduction.IntroductionViewModel
 import org.briarproject.briar.desktop.login.Login
 import org.briarproject.briar.desktop.login.LoginViewModel
@@ -55,6 +56,7 @@ constructor(
     private val registrationViewModel: RegistrationViewModel,
     private val loginViewModel: LoginViewModel,
     private val contactListViewModel: ContactListViewModel,
+    private val conversationViewModel: ConversationViewModel,
     private val addContactViewModel: AddContactViewModel,
     private val introductionViewModel: IntroductionViewModel,
     private val accountManager: AccountManager,
@@ -120,6 +122,7 @@ constructor(
                             ) {
                                 MainScreen(
                                     contactListViewModel,
+                                    conversationViewModel,
                                     addContactViewModel,
                                     introductionViewModel,
                                     isDark,
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/ui/Loader.kt b/src/main/kotlin/org/briarproject/briar/desktop/ui/Loader.kt
index 0cfb9434ca0dd1ff4691d1684008cdedde84ff40..9bd57e08271ffabdaaeb3ca43d8642cf90d4fc4d 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/ui/Loader.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/ui/Loader.kt
@@ -1,9 +1,11 @@
 package org.briarproject.briar.desktop.ui
 
+import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.padding
 import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.MaterialTheme
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
@@ -13,7 +15,7 @@ import androidx.compose.ui.unit.dp
 fun Loader() {
     Box(
         contentAlignment = Alignment.Center,
-        modifier = Modifier.fillMaxWidth().padding(20.dp)
+        modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background).padding(20.dp)
     ) {
         CircularProgressIndicator()
     }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt b/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt
index 96622e0c932e6318b7dec7a94aa5756cbff42539..69a036fd273eeddaae837ee47bd443d2cfe75ee2 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt
@@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import org.briarproject.briar.desktop.contact.ContactListViewModel
 import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel
+import org.briarproject.briar.desktop.conversation.ConversationViewModel
 import org.briarproject.briar.desktop.conversation.PrivateMessageView
 import org.briarproject.briar.desktop.introduction.IntroductionViewModel
 import org.briarproject.briar.desktop.navigation.BriarSidebar
@@ -19,6 +20,7 @@ import org.briarproject.briar.desktop.settings.PlaceHolderSettingsView
 @Composable
 fun MainScreen(
     contactListViewModel: ContactListViewModel,
+    conversationViewModel: ConversationViewModel,
     addContactViewModel: AddContactViewModel,
     introductionViewModel: IntroductionViewModel,
     isDark: Boolean,
@@ -31,7 +33,12 @@ fun MainScreen(
         BriarSidebar(uiMode, setUiMode)
         VerticalDivider()
         when (uiMode) {
-            UiMode.CONTACTS -> PrivateMessageView(contactListViewModel, addContactViewModel, introductionViewModel)
+            UiMode.CONTACTS -> PrivateMessageView(
+                contactListViewModel,
+                conversationViewModel,
+                addContactViewModel,
+                introductionViewModel
+            )
             UiMode.SETTINGS -> PlaceHolderSettingsView(isDark, setDark)
             else -> UiPlaceholder()
         }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/utils/ListUtils.kt b/src/main/kotlin/org/briarproject/briar/desktop/utils/ListUtils.kt
index 3adad7c3f2e0d7746102862e9cefa120c5e2740c..b2221c8a5275f3e4e375fb438c4677fc04111744 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/utils/ListUtils.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/utils/ListUtils.kt
@@ -1,5 +1,15 @@
 package org.briarproject.briar.desktop.utils
 
+fun <T> MutableList<T>.replaceIf(predicate: (T) -> Boolean, transformation: (T) -> T) {
+    val li = listIterator()
+    while (li.hasNext()) {
+        val n = li.next()
+        if (predicate(n)) {
+            li.set(transformation(n))
+        }
+    }
+}
+
 fun <T> MutableList<T>.replaceFirst(predicate: (T) -> Boolean, transformation: (T) -> T) {
     val li = listIterator()
     while (li.hasNext()) {