diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/AddContactDialog.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/AddContactDialog.kt
deleted file mode 100644
index 2d2f5ae5b0d2c8485a73f838a60de3ce0ce4f911..0000000000000000000000000000000000000000
--- a/src/main/kotlin/org/briarproject/briar/desktop/contact/AddContactDialog.kt
+++ /dev/null
@@ -1,142 +0,0 @@
-package org.briarproject.briar.desktop.contact
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.material.AlertDialog
-import androidx.compose.material.Button
-import androidx.compose.material.ExperimentalMaterialApi
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Text
-import androidx.compose.material.TextButton
-import androidx.compose.material.TextField
-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.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import org.briarproject.bramble.api.FormatException
-import org.briarproject.bramble.api.contact.ContactManager
-import org.briarproject.bramble.api.db.ContactExistsException
-import org.briarproject.bramble.api.db.PendingContactExistsException
-import org.briarproject.bramble.api.identity.AuthorConstants
-import org.briarproject.bramble.util.StringUtils
-import org.briarproject.briar.desktop.ui.CTM
-import java.security.GeneralSecurityException
-
-@OptIn(ExperimentalMaterialApi::class)
-@Composable
-fun AddContactDialog(isVisible: Boolean, setDialogVisibility: (Boolean) -> Unit) {
-    if (!isVisible) {
-        return
-    }
-    var contactAlias by remember { mutableStateOf("") }
-    var contactLink by remember { mutableStateOf("") }
-    val contactManager = CTM.current
-    val ownLink = CTM.current.handshakeLink
-    AlertDialog(
-        onDismissRequest = { setDialogVisibility(false) },
-        text = {
-            Column(modifier = Modifier.fillMaxWidth()) {
-                Row(Modifier.fillMaxWidth().padding(vertical = 16.dp)) {
-                    Text(
-                        text = "Add Contact at a Distance",
-                        fontSize = 24.sp,
-                        modifier = Modifier.align(Alignment.CenterVertically)
-                    )
-                }
-                Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
-                    Text(
-                        "Contact's Link",
-                        Modifier.width(128.dp).align(Alignment.CenterVertically),
-                    )
-                    TextField(contactLink, onValueChange = { contactLink = it }, modifier = Modifier.fillMaxWidth())
-                }
-                Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
-                    Text(
-                        "Contact's Name",
-                        Modifier.width(128.dp).align(Alignment.CenterVertically),
-                    )
-                    TextField(contactAlias, onValueChange = { contactAlias = it }, modifier = Modifier.fillMaxWidth())
-                }
-                Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
-                    Text(
-                        "Your Link",
-                        modifier = Modifier.width(128.dp).align(Alignment.CenterVertically),
-                    )
-                    TextField(
-                        ownLink,
-                        onValueChange = {},
-                        modifier = Modifier.fillMaxWidth()
-                    )
-                }
-            }
-        },
-        confirmButton = {
-            Button(
-                onClick = {
-                    if (ownLink.equals(contactLink)) {
-                        println("Please enter contact's link, not your own")
-                        setDialogVisibility(false)
-                        return@Button
-                    }
-                    addPendingContact(contactManager, contactAlias, contactLink)
-                    setDialogVisibility(false)
-                },
-            ) {
-                Text("Add")
-            }
-        },
-        dismissButton = {
-            TextButton(
-                onClick = { setDialogVisibility(false) }
-            ) {
-                Text("Cancel", color = MaterialTheme.colors.onSurface)
-            }
-        },
-        modifier = Modifier.size(600.dp, 300.dp),
-    )
-}
-
-private fun addPendingContact(contactManager: ContactManager, alias: String, link: String) {
-    if (aliasIsInvalid(alias)) {
-        println("Alias is invalid")
-        return
-    }
-    try {
-        contactManager.addPendingContact(link, alias)
-    } catch (e: FormatException) {
-        println("Link is invalid")
-        println(e.stackTrace)
-    } catch (e: GeneralSecurityException) {
-        println("Public key is invalid")
-        println(e.stackTrace)
-    }
-    /*
-    TODO: Warn user that the following two errors might be an attack
-
-     Use `e.pendingContact.id.bytes` and `e.pendingContact.alias` to implement the following logic:
-     https://code.briarproject.org/briar/briar-gtk/-/merge_requests/97
-
-    */
-    catch (e: ContactExistsException) {
-        println("Contact already exists")
-        println(e.stackTrace)
-    } catch (e: PendingContactExistsException) {
-        println("Pending Contact already exists")
-        println(e.stackTrace)
-    }
-}
-
-private fun aliasIsInvalid(alias: String): Boolean {
-    val aliasUtf8 = StringUtils.toUtf8(alias)
-    return aliasUtf8.isEmpty() || aliasUtf8.size > AuthorConstants.MAX_AUTHOR_NAME_LENGTH
-}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactCard.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactCard.kt
index e66b88535187b55b52547fe3f5332b820e7bf145..3a15dc7a06173669c4c87aabbf9a4ef7d6a9a68c 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactCard.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactCard.kt
@@ -19,22 +19,18 @@ 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.theme.outline
 import org.briarproject.briar.desktop.theme.selectedCard
 import org.briarproject.briar.desktop.theme.surfaceVariant
-import org.briarproject.briar.desktop.ui.CVM
 import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE
 import org.briarproject.briar.desktop.ui.HorizontalDivider
 import org.briarproject.briar.desktop.utils.TimeUtils.getFormattedTimestamp
 
 @Composable
 fun ContactCard(
-    contact: Contact,
-    selContact: Contact,
-    onSel: (Contact) -> Unit,
+    contactItem: ContactItem,
+    onSel: () -> Unit,
     selected: Boolean,
-    drawNotifications: Boolean
 ) {
     val bgColor = if (selected) MaterialTheme.colors.selectedCard else MaterialTheme.colors.surfaceVariant
     val outlineColor = MaterialTheme.colors.outline
@@ -43,7 +39,7 @@ fun ContactCard(
     val briarSurfaceVar = MaterialTheme.colors.surfaceVariant
 
     Card(
-        modifier = Modifier.fillMaxWidth().height(HEADER_SIZE).clickable(onClick = { onSel(contact) }),
+        modifier = Modifier.fillMaxWidth().height(HEADER_SIZE).clickable(onClick = onSel),
         shape = RoundedCornerShape(0.dp),
         backgroundColor = bgColor,
         contentColor = MaterialTheme.colors.onSurface
@@ -51,9 +47,9 @@ fun ContactCard(
         Row(horizontalArrangement = Arrangement.SpaceBetween) {
             Row(modifier = Modifier.align(Alignment.CenterVertically).padding(horizontal = 16.dp)) {
                 // TODO Pull profile pictures
-                ProfileCircle(36.dp, contact.author.id.bytes)
+                ProfileCircle(36.dp, contactItem.contact.author.id.bytes)
                 // Draw notification badges
-                if (drawNotifications) {
+                if (contactItem.unread > 0) {
                     Canvas(
                         modifier = Modifier.align(Alignment.CenterVertically),
                         onDraw = {
@@ -67,13 +63,12 @@ fun ContactCard(
                 }
                 Column(modifier = Modifier.align(Alignment.CenterVertically).padding(start = 12.dp)) {
                     Text(
-                        contact.author.name,
+                        contactItem.contact.author.name,
                         fontSize = 14.sp,
                         modifier = Modifier.align(Alignment.Start).padding(bottom = 2.dp)
                     )
-                    val latestMsgTime = CVM.current.getGroupCount(contact.id).latestMsgTime
                     Text(
-                        getFormattedTimestamp(latestMsgTime),
+                        if (contactItem.isEmpty) "No messages." else getFormattedTimestamp(contactItem.timestamp),
                         fontSize = 10.sp,
                         modifier = Modifier.align(Alignment.Start)
                     )
@@ -82,14 +77,12 @@ fun ContactCard(
             Canvas(
                 modifier = Modifier.padding(start = 32.dp, end = 18.dp).size(22.dp).align(Alignment.CenterVertically),
                 onDraw = {
-                    val size = 16.dp.toPx()
-                    drawCircle(color = outlineColor, radius = size / 2f)
-                    // TODO check if contact online
-                    if (true) {
-                        drawCircle(color = briarSecondary, radius = 14.dp.toPx() / 2f)
-                    } else {
-                        drawCircle(color = briarSurfaceVar, radius = 14.dp.toPx() / 2f)
-                    }
+                    val size = 16.dp
+                    drawCircle(color = outlineColor, radius = size.toPx() / 2f)
+                    drawCircle(
+                        color = if (contactItem.isConnected) briarSecondary else briarSurfaceVar,
+                        radius = (size - 2.dp).toPx() / 2f
+                    )
                 }
             )
         }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactDropDown.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactDropDown.kt
index 9740a14c63c3154b05b92580b51069e55d12b489..70c73a89f615bad46840c163eb99d03bb6b696cf 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactDropDown.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactDropDown.kt
@@ -22,7 +22,7 @@ import androidx.compose.ui.unit.sp
 fun ContactDropDown(
     expanded: Boolean,
     isExpanded: (Boolean) -> Unit,
-    setInfoDrawer: (Boolean) -> Unit
+    onMakeIntroduction: () -> Unit,
 ) {
     var connectionMode by remember { mutableStateOf(false) }
     var contactMode by remember { mutableStateOf(false) }
@@ -30,7 +30,7 @@ fun ContactDropDown(
         expanded = expanded,
         onDismissRequest = { isExpanded(false) },
     ) {
-        DropdownMenuItem(onClick = { setInfoDrawer(true); isExpanded(false) }) {
+        DropdownMenuItem(onClick = { isExpanded(false); onMakeIntroduction() }) {
             Text("Make Introduction", fontSize = 14.sp)
         }
         DropdownMenuItem(onClick = {}) {
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactInfoDrawer.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactInfoDrawer.kt
index 99f247b2d9995ae95e7568f56d8a6d3062b2e0fb..f43736a975bd38b7427f2e45fc0ba9782d447131 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactInfoDrawer.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactInfoDrawer.kt
@@ -1,8 +1,9 @@
 package org.briarproject.briar.desktop.contact
 
 import androidx.compose.runtime.Composable
-import org.briarproject.bramble.api.contact.Contact
 import org.briarproject.briar.desktop.contact.ContactInfoDrawerState.MakeIntro
+import org.briarproject.briar.desktop.introduction.ContactDrawerMakeIntro
+import org.briarproject.briar.desktop.introduction.IntroductionViewModel
 
 // Right drawer state
 enum class ContactInfoDrawerState {
@@ -13,12 +14,11 @@ enum class ContactInfoDrawerState {
 
 @Composable
 fun ContactInfoDrawer(
-    contact: Contact,
-    contacts: List<Contact>,
+    introductionViewModel: IntroductionViewModel,
     setInfoDrawer: (Boolean) -> Unit,
     drawerState: ContactInfoDrawerState
 ) {
     when (drawerState) {
-        MakeIntro -> ContactDrawerMakeIntro(contact, contacts, setInfoDrawer)
+        MakeIntro -> ContactDrawerMakeIntro(introductionViewModel, setInfoDrawer)
     }
 }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactItem.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactItem.kt
new file mode 100644
index 0000000000000000000000000000000000000000..7d18451058b97330b16cdc3074fa6d918c3a496b
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactItem.kt
@@ -0,0 +1,43 @@
+package org.briarproject.briar.desktop.contact
+
+import org.briarproject.bramble.api.contact.Contact
+import org.briarproject.briar.api.client.MessageTracker
+import org.briarproject.briar.api.conversation.ConversationMessageHeader
+import kotlin.math.max
+
+data class ContactItem(
+    val contact: Contact,
+    val isConnected: Boolean,
+    val isEmpty: Boolean,
+    val unread: Int,
+    val timestamp: Long
+) {
+
+    constructor(contact: Contact, isConnected: Boolean, groupCount: MessageTracker.GroupCount) :
+        this(
+            contact, isConnected,
+            isEmpty = groupCount.msgCount == 0,
+            unread = groupCount.unreadCount,
+            timestamp = groupCount.latestMsgTime
+        )
+
+    fun updateFromMessageHeader(h: ConversationMessageHeader): ContactItem {
+        return copy(
+            isEmpty = false,
+            unread = if (h.isRead) unread else unread + 1,
+            timestamp = max(h.timestamp, timestamp)
+        )
+    }
+
+    fun updateIsConnected(c: Boolean): ContactItem {
+        return copy(isConnected = c)
+    }
+
+    fun updateAlias(a: String?): ContactItem {
+        return copy(contact = contact.updateAlias(a))
+    }
+
+    private fun Contact.updateAlias(a: String?): Contact {
+        return Contact(id, author, localAuthorId, a, handshakePublicKey, isVerified)
+    }
+}
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 ef1d1a5a112f1c8f3f7e048df4c300557fea29e7..7fa969a5b36d8be169920ee6e3b99c961f6c1ee7 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactList.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactList.kt
@@ -5,8 +5,8 @@ import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
 import androidx.compose.material.MaterialTheme
 import androidx.compose.material.Scaffold
 import androidx.compose.runtime.Composable
@@ -16,30 +16,19 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.unit.dp
-import org.briarproject.bramble.api.contact.Contact
+import org.briarproject.briar.desktop.contact.add.remote.AddContactDialog
+import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel
 import org.briarproject.briar.desktop.theme.surfaceVariant
 import org.briarproject.briar.desktop.ui.Constants.CONTACTLIST_WIDTH
 import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE
 
 @Composable
 fun ContactList(
-    contact: Contact,
-    contacts: List<Contact>,
-    onContactSelect: (Contact) -> Unit,
-    onContactAdd: (Boolean) -> Unit
+    viewModel: ContactListViewModel,
+    addContactViewModel: AddContactViewModel,
 ) {
-    var searchValue by remember { mutableStateOf("") }
-    val filteredContacts = if (searchValue.isEmpty()) {
-        ArrayList(contacts)
-    } else {
-        val resultList = ArrayList<Contact>()
-        for (c in contacts) {
-            if (c.author.name.lowercase().contains(searchValue.lowercase())) {
-                resultList.add(c)
-            }
-        }
-        resultList
-    }
+    var isContactDialogVisible by remember { mutableStateOf(false) }
+    if (isContactDialogVisible) AddContactDialog(addContactViewModel) { isContactDialogVisible = false }
     Scaffold(
         modifier = Modifier.fillMaxHeight().width(CONTACTLIST_WIDTH),
         backgroundColor = MaterialTheme.colors.surfaceVariant,
@@ -47,13 +36,21 @@ fun ContactList(
             Column(
                 modifier = Modifier.fillMaxWidth().height(HEADER_SIZE + 1.dp),
             ) {
-                SearchTextField(searchValue, onValueChange = { searchValue = it }, onContactAdd)
+                SearchTextField(
+                    viewModel.filterBy.value,
+                    onValueChange = viewModel::setFilterBy,
+                    onContactAdd = { isContactDialogVisible = true }
+                )
             }
         },
         content = {
-            Column(Modifier.verticalScroll(rememberScrollState())) {
-                for (c in filteredContacts) {
-                    ContactCard(c, contact, onContactSelect, contact.id == c.id, true)
+            LazyColumn {
+                items(viewModel.contactList) { contactItem ->
+                    ContactCard(
+                        contactItem,
+                        { viewModel.selectContact(contactItem.contact) },
+                        viewModel.isSelected(contactItem.contact)
+                    )
                 }
             }
         },
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ee4a7f8c82a3cb0c8a86c53c409169e8f54193bb
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt
@@ -0,0 +1,73 @@
+package org.briarproject.briar.desktop.contact
+
+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.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 java.util.logging.Logger
+import javax.inject.Inject
+
+class ContactListViewModel
+@Inject
+constructor(
+    contactManager: ContactManager,
+    conversationManager: ConversationManager,
+    connectionRegistry: ConnectionRegistry,
+    eventBus: EventBus,
+) : ContactsViewModel(contactManager, conversationManager, connectionRegistry) {
+
+    companion object {
+        private val LOG = Logger.getLogger(ContactListViewModel::class.java.name)
+    }
+
+    init {
+        // todo: where/when to remove listener again?
+        eventBus.addListener(this)
+    }
+
+    private val _filterBy = mutableStateOf("")
+    private val _selectedContact = mutableStateOf<Contact?>(null)
+
+    val filterBy: State<String> = _filterBy
+    val selectedContact: State<Contact?> = _selectedContact
+
+    fun selectContact(contact: Contact) {
+        _selectedContact.value = contact
+    }
+
+    fun isSelected(contact: Contact) = _selectedContact.value?.id == contact.id
+
+    override fun filterContact(contact: Contact) =
+        // todo: also filter on alias
+        contact.author.name.contains(_filterBy.value, ignoreCase = true)
+
+    fun setFilterBy(filter: String) {
+        _filterBy.value = filter
+        updateFilteredList()
+    }
+
+    override fun updateFilteredList() {
+        super.updateFilteredList()
+        _selectedContact.value?.let { if (!filterContact(it)) _selectedContact.value = null }
+    }
+
+    override fun eventOccurred(e: Event?) {
+        super.eventOccurred(e)
+        when (e) {
+            is ConversationMessageReceivedEvent<*> -> {
+                LOG.info("Conversation message received, 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/contact/ContactsViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactsViewModel.kt
index 9e51de185321c436107891c7c371c911423967ed..ccb10ccd338ccd31c237edff5b1088034771c7bb 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactsViewModel.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactsViewModel.kt
@@ -1,32 +1,90 @@
 package org.briarproject.briar.desktop.contact
 
 import androidx.compose.runtime.mutableStateListOf
+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.ContactAddedEvent
+import org.briarproject.bramble.api.contact.event.ContactRemovedEvent
+import org.briarproject.bramble.api.event.Event
+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.briar.api.conversation.ConversationManager
+import org.briarproject.briar.desktop.utils.removeFirst
+import org.briarproject.briar.desktop.utils.replaceFirst
 import java.util.logging.Logger
-import javax.inject.Inject
 
-class ContactsViewModel
-@Inject
-constructor(
-    private val contactManager: ContactManager,
-) {
+abstract class ContactsViewModel(
+    protected val contactManager: ContactManager,
+    private val conversationManager: ConversationManager,
+    private val connectionRegistry: ConnectionRegistry
+) : EventListener {
 
     companion object {
         private val LOG = Logger.getLogger(ContactsViewModel::class.java.name)
     }
 
-    internal val contacts = mutableStateListOf<Contact>()
+    private val _fullContactList = mutableListOf<ContactItem>()
+    private val _filteredContactList = mutableStateListOf<ContactItem>()
 
-    internal fun loadContacts() {
-        val contacts = contactManager.contacts
-        for (contact in contacts) {
-            LOG.info("loaded contact: ${contact.author.name} (${contact.alias})")
-            this.contacts.add(contact)
+    val contactList: List<ContactItem> = _filteredContactList
+
+    protected open fun filterContact(contact: Contact) = true
+
+    open fun loadContacts() {
+        _fullContactList.apply {
+            clear()
+            addAll(
+                contactManager.contacts.map { contact ->
+                    ContactItem(
+                        contact,
+                        connectionRegistry.isConnected(contact.id),
+                        conversationManager.getGroupCount(contact.id),
+                    )
+                }
+            )
+        }
+        updateFilteredList()
+    }
+
+    // todo: when migrated to StateFlow, this could be done implicitly instead
+    protected open fun updateFilteredList() {
+        _filteredContactList.apply {
+            clear()
+            addAll(_fullContactList.filter { filterContact(it.contact) })
         }
     }
 
-    fun getFirst(): Contact? {
-        return if (contacts.isEmpty()) null else contacts[0]
+    override fun eventOccurred(e: Event?) {
+        when (e) {
+            is ContactAddedEvent -> {
+                LOG.info("Contact added, reloading")
+                loadContacts()
+            }
+            is ContactConnectedEvent -> {
+                LOG.info("Contact connected, update state")
+                updateItem(e.contactId) { it.updateIsConnected(true) }
+            }
+            is ContactDisconnectedEvent -> {
+                LOG.info("Contact disconnected, update state")
+                updateItem(e.contactId) { it.updateIsConnected(false) }
+            }
+            is ContactRemovedEvent -> {
+                LOG.info("Contact removed, removing item")
+                removeItem(e.contactId)
+            }
+        }
+    }
+
+    protected open fun updateItem(contactId: ContactId, update: (ContactItem) -> ContactItem) {
+        _fullContactList.replaceFirst({ it.contact.id == contactId }, update)
+        updateFilteredList()
+    }
+
+    protected open fun removeItem(contactId: ContactId) {
+        _fullContactList.removeFirst { it.contact.id == contactId }
+        updateFilteredList()
     }
 }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/SearchTextField.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/SearchTextField.kt
index 29542dd7d07d47a172f63a06202775b3e9314a1b..61d13a2f4cd48fa352f4cc2b36cd6909b483624a 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/contact/SearchTextField.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/SearchTextField.kt
@@ -22,7 +22,7 @@ import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 
 @Composable
-fun SearchTextField(searchValue: String, onValueChange: (String) -> Unit, onContactAdd: (Boolean) -> Unit) {
+fun SearchTextField(searchValue: String, onValueChange: (String) -> Unit, onContactAdd: () -> Unit) {
     TextField(
         value = searchValue,
         onValueChange = onValueChange,
@@ -36,7 +36,7 @@ fun SearchTextField(searchValue: String, onValueChange: (String) -> Unit, onCont
         },
         trailingIcon = {
             IconButton(
-                onClick = { onContactAdd(true) },
+                onClick = onContactAdd,
                 modifier = Modifier.padding(end = 10.dp).size(32.dp)
                     .background(MaterialTheme.colors.primary, CircleShape)
             ) {
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactDialog.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactDialog.kt
new file mode 100644
index 0000000000000000000000000000000000000000..db2f6dc5062a28c140c4fd422398423a66bdf596
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactDialog.kt
@@ -0,0 +1,91 @@
+package org.briarproject.briar.desktop.contact.add.remote
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.AlertDialog
+import androidx.compose.material.Button
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.material.TextButton
+import androidx.compose.material.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@OptIn(ExperimentalMaterialApi::class)
+@Composable
+fun AddContactDialog(viewModel: AddContactViewModel, onClose: () -> Unit) {
+    LaunchedEffect("fetchHandshake") {
+        // todo: should instead be done automatically as soon as DB is loaded -> in view model
+        viewModel.fetchHandshakeLink()
+    }
+    AlertDialog(
+        onDismissRequest = onClose,
+        text = {
+            Column(modifier = Modifier.fillMaxWidth()) {
+                Row(Modifier.fillMaxWidth().padding(vertical = 16.dp)) {
+                    Text(
+                        text = "Add Contact at a Distance",
+                        fontSize = 24.sp,
+                        modifier = Modifier.align(Alignment.CenterVertically)
+                    )
+                }
+                Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
+                    Text(
+                        "Contact's Link",
+                        Modifier.width(128.dp).align(Alignment.CenterVertically),
+                    )
+                    TextField(
+                        viewModel.remoteHandshakeLink.value,
+                        viewModel::setRemoteHandshakeLink,
+                        modifier = Modifier.fillMaxWidth()
+                    )
+                }
+                Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
+                    Text(
+                        "Contact's Name",
+                        Modifier.width(128.dp).align(Alignment.CenterVertically),
+                    )
+                    TextField(
+                        viewModel.alias.value,
+                        viewModel::setAddContactAlias,
+                        modifier = Modifier.fillMaxWidth()
+                    )
+                }
+                Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
+                    Text(
+                        "Your Link",
+                        modifier = Modifier.width(128.dp).align(Alignment.CenterVertically),
+                    )
+                    TextField(
+                        viewModel.handshakeLink.value,
+                        onValueChange = {},
+                        modifier = Modifier.fillMaxWidth()
+                    )
+                }
+            }
+        },
+        confirmButton = {
+            Button(onClick = { viewModel.onSubmitAddContactDialog(); onClose() }) {
+                Text("Add")
+            }
+        },
+        dismissButton = {
+            TextButton(
+                onClick = onClose
+            ) {
+                Text("Cancel", color = MaterialTheme.colors.onSurface)
+            }
+        },
+        modifier = Modifier.size(600.dp, 300.dp),
+    )
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a0b5d3ca04fac1cbf302f00fe38ad77f1deeecb7
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/AddContactViewModel.kt
@@ -0,0 +1,99 @@
+package org.briarproject.briar.desktop.contact.add.remote
+
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import org.briarproject.bramble.api.FormatException
+import org.briarproject.bramble.api.contact.ContactManager
+import org.briarproject.bramble.api.contact.HandshakeLinkConstants
+import org.briarproject.bramble.api.db.ContactExistsException
+import org.briarproject.bramble.api.db.PendingContactExistsException
+import org.briarproject.bramble.api.identity.AuthorConstants
+import org.briarproject.bramble.util.StringUtils
+import java.security.GeneralSecurityException
+import java.util.logging.Logger
+import javax.inject.Inject
+
+class AddContactViewModel
+@Inject
+constructor(
+    private val contactManager: ContactManager,
+) {
+
+    companion object {
+        private val LOG = Logger.getLogger(AddContactViewModel::class.java.name)
+    }
+
+    private val _alias = mutableStateOf("")
+    private val _remoteHandshakeLink = mutableStateOf("")
+    private val _handshakeLink = mutableStateOf("")
+
+    val alias: State<String> = _alias
+    val remoteHandshakeLink: State<String> = _remoteHandshakeLink
+    val handshakeLink: State<String> = _handshakeLink
+
+    fun setAddContactAlias(alias: String) {
+        _alias.value = alias
+    }
+
+    fun setRemoteHandshakeLink(link: String) {
+        _remoteHandshakeLink.value = link
+    }
+
+    fun fetchHandshakeLink() {
+        _handshakeLink.value = contactManager.handshakeLink
+    }
+
+    fun onSubmitAddContactDialog() {
+        val link = _remoteHandshakeLink.value
+        val alias = _alias.value
+        addPendingContact(link, alias)
+    }
+
+    private fun addPendingContact(link: String, alias: String) {
+        if (_handshakeLink.value == link) {
+            println("Please enter contact's link, not your own")
+            return
+        }
+        if (remoteHandshakeLinkIsInvalid(link)) {
+            println("Remote handshake link is invalid")
+            return
+        }
+        if (aliasIsInvalid(alias)) {
+            println("Alias is invalid")
+            return
+        }
+
+        try {
+            contactManager.addPendingContact(link, alias)
+        } catch (e: FormatException) {
+            println("Link is invalid")
+            println(e.stackTrace)
+        } catch (e: GeneralSecurityException) {
+            println("Public key is invalid")
+            println(e.stackTrace)
+        }
+        /*
+        TODO: Warn user that the following two errors might be an attack
+
+         Use `e.pendingContact.id.bytes` and `e.pendingContact.alias` to implement the following logic:
+         https://code.briarproject.org/briar/briar-gtk/-/merge_requests/97
+
+        */
+        catch (e: ContactExistsException) {
+            println("Contact already exists")
+            println(e.stackTrace)
+        } catch (e: PendingContactExistsException) {
+            println("Pending Contact already exists")
+            println(e.stackTrace)
+        }
+    }
+
+    private fun remoteHandshakeLinkIsInvalid(link: String): Boolean {
+        return !HandshakeLinkConstants.LINK_REGEX.matcher(link).find()
+    }
+
+    private fun aliasIsInvalid(alias: String): Boolean {
+        val aliasUtf8 = StringUtils.toUtf8(alias)
+        return aliasUtf8.isEmpty() || aliasUtf8.size > AuthorConstants.MAX_AUTHOR_NAME_LENGTH
+    }
+}
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 71f0cd34e0d8295041b8d35a331424c3cd62c2ed..23c61666c1115c531806d12bec299ece7df21be9 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/Conversation.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/Conversation.kt
@@ -18,6 +18,7 @@ import androidx.compose.material.MaterialTheme
 import androidx.compose.material.Scaffold
 import androidx.compose.runtime.Composable
 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
@@ -25,6 +26,7 @@ import androidx.compose.ui.unit.dp
 import org.briarproject.bramble.api.contact.Contact
 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
@@ -32,17 +34,25 @@ import org.briarproject.briar.desktop.ui.Constants.CONTACTLIST_WIDTH
 @Composable
 fun Conversation(
     contact: Contact,
-    contacts: List<Contact>,
-    expanded: Boolean,
-    setExpanded: (Boolean) -> Unit,
-    infoDrawer: Boolean,
-    setInfoDrawer: (Boolean) -> Unit,
-    drawerState: ContactInfoDrawerState
+    introductionViewModel: IntroductionViewModel,
 ) {
+    val (infoDrawer, setInfoDrawer) = remember { mutableStateOf(false) }
+    val (contactDrawerState, setDrawerState) = remember { mutableStateOf(ContactInfoDrawerState.MakeIntro) }
     BoxWithConstraints(Modifier.fillMaxSize()) {
         val animatedInfoDrawerOffsetX by animateDpAsState(if (infoDrawer) (-275).dp else 0.dp)
         Scaffold(
-            topBar = { ConversationHeader(contact, expanded, setExpanded, setInfoDrawer) },
+            topBar = {
+                ConversationHeader(
+                    contact,
+                    onMakeIntroduction = {
+                        introductionViewModel.apply {
+                            setFirstContact(contact)
+                            loadContacts()
+                        }
+                        setInfoDrawer(true)
+                    }
+                )
+            },
             content = { padding ->
                 Box(modifier = Modifier.padding(padding)) {
                     val chat = ChatState(contact.id)
@@ -71,7 +81,7 @@ fun Conversation(
                         RoundedCornerShape(topStart = 10.dp, bottomStart = 10.dp)
                     )
             ) {
-                ContactInfoDrawer(contact, contacts, setInfoDrawer, drawerState)
+                ContactInfoDrawer(introductionViewModel, setInfoDrawer, contactDrawerState)
             }
         }
     }
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 c8c45f0e05b09738cef58bafb7f40507901c3c9a..a36dca368671dfc5302184dab43c84e45e50a6a5 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationHeader.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationHeader.kt
@@ -14,6 +14,8 @@ import androidx.compose.material.Text
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.drawscope.withTransform
@@ -29,10 +31,9 @@ import org.briarproject.briar.desktop.ui.HorizontalDivider
 @Composable
 fun ConversationHeader(
     contact: Contact,
-    expanded: Boolean,
-    isExpanded: (Boolean) -> Unit,
-    setInfoDrawer: (Boolean) -> Unit
+    onMakeIntroduction: () -> Unit,
 ) {
+    val (isExpanded, setExpanded) = remember { mutableStateOf(false) }
     // TODO hook up online indicator logic
     val onlineColor = MaterialTheme.colors.secondary
     val outlineColor = MaterialTheme.colors.outline
@@ -57,11 +58,11 @@ fun ConversationHeader(
             )
         }
         IconButton(
-            onClick = { isExpanded(!expanded) },
+            onClick = { setExpanded(!isExpanded) },
             modifier = Modifier.align(Alignment.CenterEnd).padding(end = 16.dp)
         ) {
             Icon(Icons.Filled.MoreVert, "contact info", modifier = Modifier.size(24.dp))
-            ContactDropDown(expanded, isExpanded, setInfoDrawer)
+            ContactDropDown(isExpanded, setExpanded, onMakeIntroduction)
         }
         HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
     }
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 929c3f076221676d3cdc5bf951174be5a5da055a..b9dc7644181c81c97321295ee10b97a739fd32d4 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageView.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageView.kt
@@ -5,40 +5,30 @@ import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
-import org.briarproject.bramble.api.contact.Contact
-import org.briarproject.briar.desktop.contact.AddContactDialog
-import org.briarproject.briar.desktop.contact.ContactInfoDrawerState.MakeIntro
 import org.briarproject.briar.desktop.contact.ContactList
-import org.briarproject.briar.desktop.contact.ContactsViewModel
+import org.briarproject.briar.desktop.contact.ContactListViewModel
+import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel
+import org.briarproject.briar.desktop.introduction.IntroductionViewModel
+import org.briarproject.briar.desktop.ui.UiPlaceholder
 import org.briarproject.briar.desktop.ui.VerticalDivider
 
 @Composable
 fun PrivateMessageView(
-    contact: Contact,
-    contacts: ContactsViewModel,
-    onContactSelect: (Contact) -> Unit
+    contactListViewModel: ContactListViewModel,
+    addContactViewModel: AddContactViewModel,
+    introductionViewModel: IntroductionViewModel,
 ) {
-    val (isDialogVisible, setDialogVisibility) = remember { mutableStateOf(false) }
-    val (dropdownExpanded, setExpanded) = remember { mutableStateOf(false) }
-    val (infoDrawer, setInfoDrawer) = remember { mutableStateOf(false) }
-    val (contactDrawerState, setDrawerState) = remember { mutableStateOf(MakeIntro) }
-    AddContactDialog(isDialogVisible, setDialogVisibility)
     Row(modifier = Modifier.fillMaxWidth()) {
-        ContactList(contact, contacts.contacts, onContactSelect, setDialogVisibility)
+        ContactList(contactListViewModel, addContactViewModel)
         VerticalDivider()
         Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
-            Conversation(
-                contact,
-                contacts.contacts,
-                dropdownExpanded,
-                setExpanded,
-                infoDrawer,
-                setInfoDrawer,
-                contactDrawerState
-            )
+            contactListViewModel.selectedContact.value?.also { selectedContact ->
+                Conversation(
+                    selectedContact,
+                    introductionViewModel
+                )
+            } ?: UiPlaceholder()
         }
     }
 }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactDrawerMakeIntro.kt b/src/main/kotlin/org/briarproject/briar/desktop/introduction/ContactDrawerMakeIntro.kt
similarity index 56%
rename from src/main/kotlin/org/briarproject/briar/desktop/contact/ContactDrawerMakeIntro.kt
rename to src/main/kotlin/org/briarproject/briar/desktop/introduction/ContactDrawerMakeIntro.kt
index f8885da16c092b851415d2af2303b01d05c1f36c..b140e1fa65051edec6a51d59b867b5ee15013b86 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactDrawerMakeIntro.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/introduction/ContactDrawerMakeIntro.kt
@@ -1,4 +1,4 @@
-package org.briarproject.briar.desktop.contact
+package org.briarproject.briar.desktop.introduction
 
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
@@ -7,8 +7,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
 import androidx.compose.material.Icon
 import androidx.compose.material.IconButton
 import androidx.compose.material.Surface
@@ -20,44 +20,45 @@ import androidx.compose.material.icons.filled.ArrowBack
 import androidx.compose.material.icons.filled.Close
 import androidx.compose.material.icons.filled.SwapHoriz
 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.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.text.input.TextFieldValue
 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.ContactCard
+import org.briarproject.briar.desktop.contact.ProfileCircle
 import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE
 import org.briarproject.briar.desktop.ui.HorizontalDivider
 
 @Composable
-fun ContactDrawerMakeIntro(contact: Contact, contacts: List<Contact>, setInfoDrawer: (Boolean) -> Unit) {
-    var introNextPg by remember { mutableStateOf(false) }
-    val (introContact, onCancelSel) = remember { mutableStateOf(contact) }
-    if (!introNextPg) {
+fun ContactDrawerMakeIntro(
+    viewModel: IntroductionViewModel,
+    setInfoDrawer: (Boolean) -> Unit
+) {
+    if (!viewModel.secondScreen.value) {
         Surface {
-            Row(Modifier.fillMaxWidth().height(HEADER_SIZE)) {
-                IconButton(
-                    onClick = { setInfoDrawer(false) },
-                    Modifier.padding(horizontal = 11.dp).size(32.dp).align(Alignment.CenterVertically)
-                ) {
-                    Icon(Icons.Filled.Close, "close make intro screen")
+            Column {
+                Row(Modifier.fillMaxWidth().height(HEADER_SIZE)) {
+                    IconButton(
+                        onClick = { setInfoDrawer(false) },
+                        Modifier.padding(horizontal = 11.dp).size(32.dp).align(Alignment.CenterVertically)
+                    ) {
+                        Icon(Icons.Filled.Close, "close make intro screen")
+                    }
+                    Text(
+                        text = "Introduce " + viewModel.firstContact.value!!.author.name + " to:",
+                        fontSize = 16.sp,
+                        modifier = Modifier.align(Alignment.CenterVertically)
+                    )
                 }
-                Text(
-                    text = "Introduce " + contact.author.name + " to:",
-                    fontSize = 16.sp,
-                    modifier = Modifier.align(Alignment.CenterVertically)
-                )
-            }
-            HorizontalDivider()
-            Column(Modifier.verticalScroll(rememberScrollState())) {
-                for (c in contacts) {
-                    if (c.id != contact.id) {
-                        ContactCard(c, contact, { onCancelSel(c); introNextPg = true }, false, false)
+                HorizontalDivider()
+                LazyColumn {
+                    items(viewModel.contactList) { contactItem ->
+                        ContactCard(
+                            contactItem,
+                            { viewModel.setSecondContact(contactItem.contact) },
+                            false
+                        )
                     }
                 }
             }
@@ -66,7 +67,7 @@ fun ContactDrawerMakeIntro(contact: Contact, contacts: List<Contact>, setInfoDra
         Column {
             Row(Modifier.fillMaxWidth().height(HEADER_SIZE)) {
                 IconButton(
-                    onClick = { introNextPg = false },
+                    onClick = viewModel::backToFirstScreen,
                     Modifier.padding(horizontal = 11.dp).size(32.dp).align(Alignment.CenterVertically)
                 ) {
                     Icon(Icons.Filled.ArrowBack, "go back to make intro contact screen", tint = Color.White)
@@ -79,26 +80,25 @@ fun ContactDrawerMakeIntro(contact: Contact, contacts: List<Contact>, setInfoDra
             }
             Row(Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.SpaceAround) {
                 Column(Modifier.align(Alignment.CenterVertically)) {
-                    ProfileCircle(36.dp, contact.author.id.bytes)
-                    Text(contact.author.name, Modifier.padding(top = 4.dp), Color.White, 16.sp)
+                    ProfileCircle(36.dp, viewModel.firstContact.value!!.author.id.bytes)
+                    Text(viewModel.firstContact.value!!.author.name, Modifier.padding(top = 4.dp), Color.White, 16.sp)
                 }
                 Icon(Icons.Filled.SwapHoriz, "swap", modifier = Modifier.size(48.dp))
                 Column(Modifier.align(Alignment.CenterVertically)) {
-                    ProfileCircle(36.dp, introContact.author.id.bytes)
-                    Text(introContact.author.name, Modifier.padding(top = 4.dp), Color.White, 16.sp)
+                    ProfileCircle(36.dp, viewModel.secondContact.value!!.author.id.bytes)
+                    Text(viewModel.secondContact.value!!.author.name, Modifier.padding(top = 4.dp), Color.White, 16.sp)
                 }
             }
-            var introText by remember { mutableStateOf(TextFieldValue("")) }
             Row(Modifier.padding(8.dp)) {
                 TextField(
-                    introText,
-                    { introText = it },
+                    viewModel.introductionMessage.value,
+                    viewModel::setIntroductionMessage,
                     placeholder = { Text(text = "Add a message (optional)") },
                 )
             }
             Row(Modifier.padding(8.dp)) {
                 TextButton(
-                    onClick = { setInfoDrawer(false); introNextPg = false; },
+                    onClick = { setInfoDrawer(false) },
                     Modifier.fillMaxWidth()
                 ) {
                     Text("MAKE INTRODUCTION")
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/introduction/IntroductionViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/introduction/IntroductionViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..fda0aa64dc456a881146eeb9992d716b7f2f5a6b
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/introduction/IntroductionViewModel.kt
@@ -0,0 +1,65 @@
+package org.briarproject.briar.desktop.introduction
+
+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.ContactManager
+import org.briarproject.bramble.api.event.EventBus
+import org.briarproject.briar.api.conversation.ConversationManager
+import org.briarproject.briar.desktop.contact.ContactsViewModel
+import java.util.logging.Logger
+import javax.inject.Inject
+
+class IntroductionViewModel
+@Inject
+constructor(
+    contactManager: ContactManager,
+    conversationManager: ConversationManager,
+    connectionRegistry: ConnectionRegistry,
+    eventBus: EventBus,
+) : ContactsViewModel(contactManager, conversationManager, connectionRegistry) {
+
+    companion object {
+        private val LOG = Logger.getLogger(IntroductionViewModel::class.java.name)
+    }
+
+    init {
+        // todo: where/when to remove listener again?
+        eventBus.addListener(this)
+    }
+
+    private val _firstContact = mutableStateOf<Contact?>(null)
+    private val _secondContact = mutableStateOf<Contact?>(null)
+    private val _secondScreen = mutableStateOf(false)
+    private val _introductionMessage = mutableStateOf("")
+
+    val firstContact: State<Contact?> = _firstContact
+    val secondContact: State<Contact?> = _secondContact
+    val secondScreen: State<Boolean> = _secondScreen
+    val introductionMessage: State<String> = _introductionMessage
+
+    fun setFirstContact(contact: Contact) {
+        _firstContact.value = contact
+        loadContacts()
+        backToFirstScreen()
+    }
+
+    fun setSecondContact(contact: Contact) {
+        _secondContact.value = contact
+        _secondScreen.value = true
+    }
+
+    fun backToFirstScreen() {
+        _secondScreen.value = false
+        _introductionMessage.value = ""
+    }
+
+    fun setIntroductionMessage(msg: String) {
+        _introductionMessage.value = msg
+    }
+
+    override fun filterContact(contact: Contact): Boolean {
+        return _firstContact.value?.id != contact.id
+    }
+}
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 5fa8ea0d60bf2e40353233dd4e6b62f0a7017026..f621dab9eb5a47577d6700d6010fb7fc320d56d2 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt
@@ -15,7 +15,9 @@ import org.briarproject.bramble.api.lifecycle.LifecycleManager
 import org.briarproject.bramble.api.lifecycle.LifecycleManager.LifecycleState.RUNNING
 import org.briarproject.briar.api.conversation.ConversationManager
 import org.briarproject.briar.api.messaging.MessagingManager
-import org.briarproject.briar.desktop.contact.ContactsViewModel
+import org.briarproject.briar.desktop.contact.ContactListViewModel
+import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel
+import org.briarproject.briar.desktop.introduction.IntroductionViewModel
 import org.briarproject.briar.desktop.login.Login
 import org.briarproject.briar.desktop.login.LoginViewModel
 import org.briarproject.briar.desktop.login.Registration
@@ -52,7 +54,9 @@ internal class BriarUiImpl
 constructor(
     private val registrationViewModel: RegistrationViewModel,
     private val loginViewModel: LoginViewModel,
-    private val contactsViewModel: ContactsViewModel,
+    private val contactListViewModel: ContactListViewModel,
+    private val addContactViewModel: AddContactViewModel,
+    private val introductionViewModel: IntroductionViewModel,
     private val accountManager: AccountManager,
     private val contactManager: ContactManager,
     private val conversationManager: ConversationManager,
@@ -82,7 +86,7 @@ constructor(
                     if (accountManager.hasDatabaseKey()) {
                         // this should only happen during testing when we launch the main UI directly
                         // without a need to enter the password.
-                        contactsViewModel.loadContacts()
+                        contactListViewModel.loadContacts()
                         Screen.MAIN
                     } else if (accountManager.accountExists()) {
                         Screen.LOGIN
@@ -100,12 +104,11 @@ constructor(
                     when (screenState) {
                         Screen.REGISTRATION ->
                             Registration(registrationViewModel) {
-                                contactsViewModel.loadContacts()
                                 screenState = Screen.MAIN
                             }
                         Screen.LOGIN ->
                             Login(loginViewModel) {
-                                contactsViewModel.loadContacts()
+                                contactListViewModel.loadContacts()
                                 screenState = Screen.MAIN
                             }
                         else ->
@@ -115,7 +118,13 @@ constructor(
                                 MM provides messagingManager,
                                 IM provides identityManager,
                             ) {
-                                MainScreen(contactsViewModel, isDark, setDark)
+                                MainScreen(
+                                    contactListViewModel,
+                                    addContactViewModel,
+                                    introductionViewModel,
+                                    isDark,
+                                    setDark
+                                )
                             }
                     }
                 }
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 9a27637bbebd2921e4f4149938f851db1476ecd6..96622e0c932e6318b7dec7a94aa5756cbff42539 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt
@@ -1,17 +1,13 @@
 package org.briarproject.briar.desktop.ui
 
-import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
-import androidx.compose.ui.Modifier
-import org.briarproject.briar.desktop.contact.ContactsViewModel
+import org.briarproject.briar.desktop.contact.ContactListViewModel
+import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel
 import org.briarproject.briar.desktop.conversation.PrivateMessageView
+import org.briarproject.briar.desktop.introduction.IntroductionViewModel
 import org.briarproject.briar.desktop.navigation.BriarSidebar
 import org.briarproject.briar.desktop.settings.PlaceHolderSettingsView
 
@@ -22,29 +18,22 @@ import org.briarproject.briar.desktop.settings.PlaceHolderSettingsView
  */
 @Composable
 fun MainScreen(
-    contactsViewModel: ContactsViewModel,
+    contactListViewModel: ContactListViewModel,
+    addContactViewModel: AddContactViewModel,
+    introductionViewModel: IntroductionViewModel,
     isDark: Boolean,
     setDark: (Boolean) -> Unit
 ) {
     // current selected mode, changed using the sidebar buttons
     val (uiMode, setUiMode) = remember { mutableStateOf(UiMode.CONTACTS) }
-    // TODO Figure out how to handle accounts with 0 contacts
-    // current selected contact
-    val (contact, setContact) = remember { mutableStateOf(contactsViewModel.getFirst()) }
     // Other global state that we need to track should go here also
     Row {
         BriarSidebar(uiMode, setUiMode)
         VerticalDivider()
         when (uiMode) {
-            UiMode.CONTACTS -> if (contact != null) PrivateMessageView(
-                contact,
-                contactsViewModel,
-                setContact
-            )
+            UiMode.CONTACTS -> PrivateMessageView(contactListViewModel, addContactViewModel, introductionViewModel)
             UiMode.SETTINGS -> PlaceHolderSettingsView(isDark, setDark)
-            else -> Surface(modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background)) {
-                Text("TBD")
-            }
+            else -> UiPlaceholder()
         }
     }
 }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/ui/UiPlaceholder.kt b/src/main/kotlin/org/briarproject/briar/desktop/ui/UiPlaceholder.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5b44eea15691334a7dc6b95ac749e889aaa0f730
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/ui/UiPlaceholder.kt
@@ -0,0 +1,14 @@
+package org.briarproject.briar.desktop.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+@Composable
+fun UiPlaceholder() = Surface(Modifier.fillMaxSize().background(MaterialTheme.colors.background)) {
+    Text("TBD")
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/utils/ListUtils.kt b/src/main/kotlin/org/briarproject/briar/desktop/utils/ListUtils.kt
new file mode 100644
index 0000000000000000000000000000000000000000..3adad7c3f2e0d7746102862e9cefa120c5e2740c
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/utils/ListUtils.kt
@@ -0,0 +1,23 @@
+package org.briarproject.briar.desktop.utils
+
+fun <T> MutableList<T>.replaceFirst(predicate: (T) -> Boolean, transformation: (T) -> T) {
+    val li = listIterator()
+    while (li.hasNext()) {
+        val n = li.next()
+        if (predicate(n)) {
+            li.set(transformation(n))
+            break
+        }
+    }
+}
+
+fun <T> MutableList<T>.removeFirst(predicate: (T) -> Boolean) {
+    val li = listIterator()
+    while (li.hasNext()) {
+        val n = li.next()
+        if (predicate(n)) {
+            li.remove()
+            break
+        }
+    }
+}