diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupCard.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupCard.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b6f75800bcd7c2f67ef73aad116c2c4d6f41ed99
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupCard.kt
@@ -0,0 +1,81 @@
+package org.briarproject.briar.desktop.privategroups
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.clickable
+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.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.Card
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+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.briar.desktop.contact.ProfileCircle
+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.Constants.HEADER_SIZE
+import org.briarproject.briar.desktop.ui.HorizontalDivider
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+import org.briarproject.briar.desktop.utils.TimeUtils.getFormattedTimestamp
+
+@Composable
+fun PrivateGroupCard(
+    privateGroupItem: PrivateGroupItem,
+    onSel: () -> Unit,
+    selected: Boolean,
+) {
+    val bgColor = if (selected) MaterialTheme.colors.selectedCard else MaterialTheme.colors.surfaceVariant
+    val outlineColor = MaterialTheme.colors.outline
+    val briarPrimary = MaterialTheme.colors.primary
+    val briarSecondary = MaterialTheme.colors.secondary
+    val briarSurfaceVar = MaterialTheme.colors.surfaceVariant
+
+    Card(
+        modifier = Modifier.fillMaxWidth().height(HEADER_SIZE).clickable(onClick = onSel),
+        shape = RoundedCornerShape(0.dp),
+        backgroundColor = bgColor,
+        contentColor = MaterialTheme.colors.onSurface
+    ) {
+        Row(horizontalArrangement = Arrangement.SpaceBetween) {
+            Row(modifier = Modifier.align(Alignment.CenterVertically).padding(horizontal = 16.dp)) {
+                // TODO Pull profile pictures
+                ProfileCircle(36.dp, privateGroupItem.privateGroup.id.bytes)
+                // Draw notification badges
+                if (privateGroupItem.unread > 0) {
+                    Canvas(
+                        modifier = Modifier.align(Alignment.CenterVertically),
+                        onDraw = {
+                            val size = 10.dp.toPx()
+                            withTransform({ translate(left = -6f, top = -12f) }) {
+                                drawCircle(color = outlineColor, radius = (size + 2.dp.toPx()) / 2f)
+                                drawCircle(color = briarPrimary, radius = size / 2f)
+                            }
+                        }
+                    )
+                }
+                Column(modifier = Modifier.align(Alignment.CenterVertically).padding(start = 12.dp)) {
+                    Text(
+                        privateGroupItem.privateGroup.name,
+                        fontSize = 14.sp,
+                        modifier = Modifier.align(Alignment.Start).padding(bottom = 2.dp)
+                    )
+                    Text(
+                        if (privateGroupItem.isEmpty) i18n("contacts.card.nothing") else getFormattedTimestamp(privateGroupItem.timestamp),
+                        fontSize = 10.sp,
+                        modifier = Modifier.align(Alignment.Start)
+                    )
+                }
+            }
+        }
+    }
+    HorizontalDivider()
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupInfoDrawer.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupInfoDrawer.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ae32c89a81a0a8994801582ac9c6256675916eab
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupInfoDrawer.kt
@@ -0,0 +1,25 @@
+package org.briarproject.briar.desktop.privategroups
+
+import androidx.compose.runtime.Composable
+import org.briarproject.briar.api.privategroup.PrivateGroup
+import org.briarproject.briar.desktop.contact.ContactInfoDrawerState
+
+// Right drawer state
+enum class PrivateGroupInfoDrawerState {
+    MakeIntro,
+    ConnectBT,
+    ConnectRD
+}
+
+@Composable
+fun ContactInfoDrawer(
+    privateGroup: PrivateGroup,
+    setInfoDrawer: (Boolean) -> Unit,
+    drawerState: ContactInfoDrawerState
+) {
+    /* TODO
+    when (drawerState) {
+        MakeIntro -> ContactDrawerMakeIntro(privateGroup, setInfoDrawer)
+    }
+     */
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupItem.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupItem.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b6677fc3e7b75a4553d94e13a3909406b3fc0bb2
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupItem.kt
@@ -0,0 +1,38 @@
+package org.briarproject.briar.desktop.privategroups
+
+import org.briarproject.briar.api.client.MessageTracker
+import org.briarproject.briar.api.privategroup.GroupMessageHeader
+import org.briarproject.briar.api.privategroup.PrivateGroup
+import kotlin.math.max
+
+data class PrivateGroupItem(
+    val privateGroup: PrivateGroup,
+    val isEmpty: Boolean,
+    val unread: Int,
+    val timestamp: Long
+) {
+
+    constructor(privateGroup: PrivateGroup, groupCount: MessageTracker.GroupCount) :
+        this(
+            privateGroup,
+            isEmpty = groupCount.msgCount == 0,
+            unread = groupCount.unreadCount,
+            timestamp = groupCount.latestMsgTime
+        )
+
+    fun updateFromMessageHeader(h: GroupMessageHeader): PrivateGroupItem {
+        return copy(
+            isEmpty = false,
+            unread = if (h.isRead) unread else unread + 1,
+            timestamp = max(h.timestamp, timestamp)
+        )
+    }
+
+    fun updateName(name: String): PrivateGroupItem {
+        return copy(privateGroup = privateGroup.updateName(name))
+    }
+
+    private fun PrivateGroup.updateName(name: String): PrivateGroup {
+        return PrivateGroup(group, name, creator, salt)
+    }
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupList.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupList.kt
new file mode 100644
index 0000000000000000000000000000000000000000..10304c6115c8fbb1d8dc44faf452459baa95ffc5
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupList.kt
@@ -0,0 +1,62 @@
+package org.briarproject.briar.desktop.privategroups
+
+import androidx.compose.foundation.layout.Column
+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.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+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.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.briarproject.bramble.api.sync.GroupId
+import org.briarproject.briar.desktop.contact.SearchTextField
+import org.briarproject.briar.desktop.contact.add.remote.AddContactDialog
+import org.briarproject.briar.desktop.theme.surfaceVariant
+import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE
+import org.briarproject.briar.desktop.ui.Constants.PRIVATE_GROUP_LIST_WIDTH
+
+@Composable
+fun PrivateGroupList(
+    privateGroupList: List<PrivateGroupItem>,
+    isSelected: (GroupId) -> Boolean,
+    selectPrivateGroup: (GroupId) -> Unit,
+    filterBy: String,
+    setFilterBy: (String) -> Unit,
+) {
+    var isCreatePrivateGroupDialogVisible by remember { mutableStateOf(false) }
+    if (isCreatePrivateGroupDialogVisible) AddContactDialog(onClose = { isCreatePrivateGroupDialogVisible = false })
+    Scaffold(
+        modifier = Modifier.fillMaxHeight().width(PRIVATE_GROUP_LIST_WIDTH),
+        backgroundColor = MaterialTheme.colors.surfaceVariant,
+        topBar = {
+            Column(
+                modifier = Modifier.fillMaxWidth().height(HEADER_SIZE + 1.dp),
+            ) {
+                SearchTextField(
+                    filterBy,
+                    onValueChange = setFilterBy,
+                    onContactAdd = { isCreatePrivateGroupDialogVisible = true }
+                )
+            }
+        },
+        content = {
+            LazyColumn {
+                items(privateGroupList) { privateGroupItem ->
+                    PrivateGroupCard(
+                        privateGroupItem,
+                        { selectPrivateGroup(privateGroupItem.privateGroup.id) },
+                        isSelected(privateGroupItem.privateGroup.id)
+                    )
+                }
+            }
+        },
+    )
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupListViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupListViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d3f27d8443d961fa24a61de0c8fece1bb835d71c
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupListViewModel.kt
@@ -0,0 +1,131 @@
+package org.briarproject.briar.desktop.privategroups
+
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import org.briarproject.bramble.api.connection.ConnectionRegistry
+import org.briarproject.bramble.api.contact.ContactId
+import org.briarproject.bramble.api.event.Event
+import org.briarproject.bramble.api.event.EventBus
+import org.briarproject.bramble.api.sync.GroupId
+import org.briarproject.briar.api.conversation.ConversationManager
+import org.briarproject.briar.api.privategroup.PrivateGroup
+import org.briarproject.briar.api.privategroup.PrivateGroupManager
+import org.briarproject.briar.desktop.utils.removeFirst
+import org.briarproject.briar.desktop.utils.replaceFirst
+import org.briarproject.briar.desktop.viewmodel.BriarEventListenerViewModel
+import java.util.logging.Logger
+import javax.inject.Inject
+
+class PrivateGroupListViewModel
+@Inject
+constructor(
+    val privateGroupManager: PrivateGroupManager,
+    val conversationManager: ConversationManager,
+    val connectionRegistry: ConnectionRegistry,
+    eventBus: EventBus,
+) : BriarEventListenerViewModel(eventBus) {
+
+    companion object {
+        private val LOG = Logger.getLogger(PrivateGroupListViewModel::class.java.name)
+    }
+
+    private val _fullContactList = mutableListOf<PrivateGroupItem>()
+    private val _filteredContactList = mutableStateListOf<PrivateGroupItem>()
+
+    val privateGroupList: List<PrivateGroupItem> = _filteredContactList
+
+    private fun loadPrivateGroups() {
+        _fullContactList.apply {
+            clear()
+            addAll(
+                privateGroupManager.privateGroups.map { privateGroup ->
+                    PrivateGroupItem(
+                        privateGroup,
+                        privateGroupManager.getGroupCount(privateGroup.id),
+                    )
+                }
+            )
+        }
+        updateFilteredList()
+    }
+
+    private fun updateItem(contactId: ContactId, update: (PrivateGroupItem) -> PrivateGroupItem) {
+        _fullContactList.replaceFirst({ it.privateGroup.id == contactId }, update)
+        updateFilteredList()
+    }
+
+    private fun removeItem(groupId: GroupId) {
+        _fullContactList.removeFirst { it.privateGroup.id == groupId }
+        updateFilteredList()
+    }
+
+    override fun onInit() {
+        super.onInit()
+        loadPrivateGroups()
+    }
+
+    private val _filterBy = mutableStateOf("")
+    private val _selectedContactId = mutableStateOf<GroupId?>(null)
+
+    val filterBy: State<String> = _filterBy
+    val selectedPrivateGroupId: State<GroupId?> = _selectedContactId
+
+    fun selectPrivateGroup(privateGroupId: GroupId) {
+        _selectedContactId.value = privateGroupId
+    }
+
+    fun isSelected(privateGroupId: GroupId) = _selectedContactId.value == privateGroupId
+
+    private fun filterContact(privateGroup: PrivateGroup) =
+        // todo: also filter on alias
+        privateGroup.name.contains(_filterBy.value, ignoreCase = true)
+
+    fun setFilterBy(filter: String) {
+        _filterBy.value = filter
+        updateFilteredList()
+    }
+
+    // todo: when migrated to StateFlow, this could be done implicitly instead
+    fun updateFilteredList() {
+        _filteredContactList.apply {
+            clear()
+            addAll(_fullContactList.filter { filterContact(it.privateGroup) }.sortedByDescending { it.timestamp })
+        }
+
+        // reset selected contact to null if not available after filtering
+        val id = _selectedContactId.value
+        if (id != null && !privateGroupList.map { it.privateGroup.id }.contains(id)) {
+            _selectedContactId.value = null
+        }
+    }
+
+    override fun eventOccurred(e: Event?) {
+        /*
+        when (e) {
+            is ContactAddedEvent -> {
+                LOG.info("Contact added, reloading")
+                loadPrivateGroups()
+            }
+            is ContactRemovedEvent -> {
+                LOG.info("Contact removed, removing item")
+                removeItem(e.contactId)
+            }
+        }
+        when (e) {
+            is ConversationMessageReceivedEvent<*> -> {
+                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/privategroups/PrivateGroupScreen.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupScreen.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2046b4689db0c5ae9fc31d991d45aab6af81972b
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/PrivateGroupScreen.kt
@@ -0,0 +1,35 @@
+package org.briarproject.briar.desktop.privategroups
+
+import androidx.compose.foundation.layout.Column
+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.ui.Modifier
+import org.briarproject.briar.desktop.ui.UiPlaceholder
+import org.briarproject.briar.desktop.ui.VerticalDivider
+import org.briarproject.briar.desktop.viewmodel.viewModel
+
+@Composable
+fun PrivateGroupScreen(
+    viewModel: PrivateGroupListViewModel = viewModel(),
+) {
+    Row(modifier = Modifier.fillMaxWidth()) {
+        PrivateGroupList(
+            viewModel.privateGroupList,
+            viewModel::isSelected,
+            viewModel::selectPrivateGroup,
+            viewModel.filterBy.value,
+            viewModel::setFilterBy
+        )
+        VerticalDivider()
+        Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
+            val id = viewModel.selectedPrivateGroupId.value
+            if (id != null) {
+                ThreadedConversationScreen(id)
+            } else {
+                UiPlaceholder()
+            }
+        }
+    }
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationHeader.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationHeader.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5146a684aa0f5fcbc86429e9a5526c39ea383d5c
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationHeader.kt
@@ -0,0 +1,55 @@
+package org.briarproject.briar.desktop.privategroups
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+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.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.MaterialTheme
+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.unit.dp
+import androidx.compose.ui.unit.sp
+import org.briarproject.briar.desktop.contact.ContactDropDown
+import org.briarproject.briar.desktop.contact.ProfileCircle
+import org.briarproject.briar.desktop.theme.outline
+import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE
+import org.briarproject.briar.desktop.ui.HorizontalDivider
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+
+@Composable
+fun ThreadedConversationHeader(
+    privateGroupItem: PrivateGroupItem,
+    onMakeIntroduction: () -> Unit,
+) {
+    val (isExpanded, setExpanded) = remember { mutableStateOf(false) }
+    val outlineColor = MaterialTheme.colors.outline
+
+    Box(modifier = Modifier.fillMaxWidth().height(HEADER_SIZE + 1.dp)) {
+        Row(modifier = Modifier.align(Alignment.Center)) {
+            ProfileCircle(36.dp, privateGroupItem.privateGroup.id.bytes)
+            Text(
+                privateGroupItem.privateGroup.name,
+                modifier = Modifier.align(Alignment.CenterVertically).padding(start = 12.dp),
+                fontSize = 20.sp
+            )
+        }
+        IconButton(
+            onClick = { setExpanded(!isExpanded) },
+            modifier = Modifier.align(Alignment.CenterEnd).padding(end = 16.dp)
+        ) {
+            Icon(Icons.Filled.MoreVert, i18n("access.contact.menu"), modifier = Modifier.size(24.dp))
+            ContactDropDown(isExpanded, setExpanded, onMakeIntroduction)
+        }
+        HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
+    }
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationItem.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationItem.kt
new file mode 100644
index 0000000000000000000000000000000000000000..4ba592fd8867e9ebb646d30e5783c507d944fd73
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationItem.kt
@@ -0,0 +1,31 @@
+package org.briarproject.briar.desktop.privategroups
+
+import org.briarproject.bramble.api.sync.GroupId
+import org.briarproject.bramble.api.sync.MessageId
+
+sealed class ThreadedConversationItem {
+    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): ThreadedConversationItem
+
+    abstract fun markRead(): ThreadedConversationItem
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationMessageItem.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationMessageItem.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f9b64bd3a4061aeeaef8912f117be08bdcb3acdd
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationMessageItem.kt
@@ -0,0 +1,41 @@
+package org.briarproject.briar.desktop.privategroups
+
+import org.briarproject.bramble.api.sync.GroupId
+import org.briarproject.bramble.api.sync.MessageId
+import org.briarproject.briar.api.conversation.ConversationMessageHeader
+
+data class ThreadedConversationMessageItem(
+    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>
+) : ThreadedConversationItem() {
+
+    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): ThreadedConversationItem {
+        return copy(isSent = sent, isSeen = seen)
+    }
+
+    override fun markRead(): ThreadedConversationItem {
+        return copy(isRead = true)
+    }
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationScreen.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationScreen.kt
new file mode 100644
index 0000000000000000000000000000000000000000..db0b50ffc31f9f53eee2cc97b7d10beccdd50bc4
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationScreen.kt
@@ -0,0 +1,116 @@
+package org.briarproject.briar.desktop.privategroups
+
+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.sync.GroupId
+import org.briarproject.briar.desktop.contact.ContactInfoDrawer
+import org.briarproject.briar.desktop.contact.ContactInfoDrawerState
+import org.briarproject.briar.desktop.conversation.ConversationInput
+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
+import org.briarproject.briar.desktop.viewmodel.viewModel
+
+@Composable
+fun ThreadedConversationScreen(
+    groupId: GroupId,
+    viewModel: ThreadedConversationViewModel = viewModel(),
+) {
+    LaunchedEffect(groupId) {
+        viewModel.setGroupId(groupId)
+    }
+
+    val contactItem = viewModel.contactItem.value
+
+    if (contactItem == null) {
+        Loader()
+        return
+    }
+
+    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 = {
+                ThreadedConversationHeader(
+                    contactItem,
+                    onMakeIntroduction = {
+                        setInfoDrawer(true)
+                    }
+                )
+            },
+            content = { padding ->
+                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(viewModel.messages) { m ->
+                        if (m is ThreadedConversationMessageItem)
+                            ThreadedText(m)
+                    }
+                }
+            },
+            bottomBar = {
+                ConversationInput(
+                    viewModel.newMessage.value,
+                    viewModel::setNewMessage,
+                    viewModel::sendMessage
+                )
+            },
+        )
+        if (infoDrawer) {
+            // TODO Find non-hacky way of setting scrim on entire app
+            Box(
+                Modifier.offset(-(CONTACTLIST_WIDTH + SIDEBAR_WIDTH))
+                    .requiredSize(maxWidth + CONTACTLIST_WIDTH + SIDEBAR_WIDTH, maxHeight)
+                    .background(Color(0, 0, 0, 100))
+                    .clickable(
+                        // prevent visual indication
+                        interactionSource = remember { MutableInteractionSource() },
+                        indication = null
+                    ) { setInfoDrawer(false) }
+            )
+            Column(
+                modifier = Modifier.fillMaxHeight().width(CONTACTLIST_WIDTH)
+                    .offset(maxWidth + animatedInfoDrawerOffsetX)
+                    .background(
+                        MaterialTheme.colors.surfaceVariant,
+                        RoundedCornerShape(topStart = 10.dp, bottomStart = 10.dp)
+                    )
+            ) {
+                ContactInfoDrawer(contactItem.privateGroup, setInfoDrawer, contactDrawerState)
+            }
+        }
+    }
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..05bc249a66785d6448ac61ebe24c8d442047fee6
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedConversationViewModel.kt
@@ -0,0 +1,232 @@
+package org.briarproject.briar.desktop.privategroups
+
+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.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.sync.GroupId
+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.api.privategroup.PrivateGroupManager
+import org.briarproject.briar.desktop.utils.replaceIf
+import org.briarproject.briar.desktop.viewmodel.BriarEventListenerViewModel
+import java.util.Date
+import java.util.logging.Level
+import java.util.logging.Logger
+import javax.inject.Inject
+
+class ThreadedConversationViewModel
+@Inject
+constructor(
+    private val privateGroupManager: PrivateGroupManager,
+    private val conversationManager: ConversationManager,
+    private val messagingManager: MessagingManager,
+    private val privateMessageFactory: PrivateMessageFactory,
+    private val eventBus: EventBus,
+) : BriarEventListenerViewModel(eventBus) {
+
+    companion object {
+        private val LOG = Logger.getLogger(ThreadedConversationViewModel::class.java.name)
+    }
+
+    private val _privateGroupId = mutableStateOf<GroupId?>(null)
+    private val _privateGroupItem = mutableStateOf<PrivateGroupItem?>(null)
+    private val _messages = mutableStateListOf<ThreadedConversationItem>()
+
+    private val _newMessage = mutableStateOf("")
+
+    val contactItem: State<PrivateGroupItem?> = _privateGroupItem
+    val messages: List<ThreadedConversationItem> = _messages
+
+    val newMessage: State<String> = _newMessage
+
+    fun setGroupId(id: GroupId) {
+        if (_privateGroupId.value == id)
+            return
+
+        _privateGroupId.value = id
+        _privateGroupItem.value = PrivateGroupItem(
+            privateGroupManager.getPrivateGroup(id),
+            privateGroupManager.getGroupCount(id),
+        )
+        loadMessages()
+        setNewMessage("")
+    }
+
+    fun setNewMessage(msg: String) {
+        _newMessage.value = msg
+    }
+
+    fun sendMessage() {
+        try {
+            val text = _newMessage.value
+            _newMessage.value = ""
+
+            // don't send empty or blank messages
+            if (text.isBlank()) return
+
+            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 = _privateGroupItem.value!!.privateGroup.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 = privateGroupManager.getHeaders(_privateGroupId.value!!)
+            LogUtils.logDuration(LOG, "Loading message headers", start)
+            // Sort headers by timestamp in *descending* order
+            val sorted = headers.sortedByDescending { it.timestamp }
+            _messages.apply {
+                clear()
+                val start = LogUtils.now()
+                addAll(
+                    // todo: use ConversationVisitor to also display Request and Notice Messages
+                    sorted.filterIsInstance<PrivateMessageHeader>().map(::messageHeaderToItem)
+                )
+                LogUtils.logDuration(LOG, "Loading messages", start)
+            }
+        } catch (e: NoSuchContactException) {
+            LogUtils.logException(LOG, Level.WARNING, e)
+        } catch (e: DbException) {
+            LogUtils.logException(LOG, Level.WARNING, e)
+        }
+    }
+
+    private fun messageHeaderToItem(h: PrivateMessageHeader): ThreadedConversationMessageItem {
+        // todo: use ConversationVisitor instead and support other MessageHeader
+        val item = ThreadedConversationMessageItem(h)
+        if (h.hasText()) {
+            item.text = loadMessageText(h.id)
+        } else {
+            LOG.warning { "private message without text" }
+        }
+        return item
+    }
+
+    private fun loadMessageText(m: MessageId): String? {
+        try {
+            return messagingManager.getMessageText(m)
+        } catch (e: DbException) {
+            LogUtils.logException(LOG, Level.WARNING, e)
+        }
+        return null
+    }
+
+    override fun eventOccurred(e: Event?) {
+        when (e) {
+            is ContactRemovedEvent -> {
+                if (e.contactId == _privateGroupId.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 == _privateGroupId.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 == _privateGroupId.value) {
+                    LOG.info("Messages sent")
+                    markMessages(e.messageIds, sent = true, seen = false)
+                }
+            }
+            is MessagesAckedEvent -> {
+                if (e.contactId == _privateGroupId.value) {
+                    LOG.info("Messages acked")
+                    markMessages(e.messageIds, sent = true, seen = true)
+                }
+            }
+            is ConversationMessagesDeletedEvent -> {
+                if (e.contactId == _privateGroupId.value) {
+                    LOG.info("Messages auto-deleted")
+                    val messages = HashSet(e.messageIds)
+                    _messages.removeIf { messages.contains(it.id) }
+                }
+            }
+            /*
+            is ContactConnectedEvent -> {
+                if (e.contactId == _privateGroupId.value) {
+                    LOG.info("Contact connected")
+                    _privateGroupItem.value = _privateGroupItem.value!!.updateIsConnected(true)
+                }
+            }
+            is ContactDisconnectedEvent -> {
+                if (e.contactId == _privateGroupId.value) {
+                    LOG.info("Contact disconnected")
+                    _privateGroupItem.value = _privateGroupItem.value!!.updateIsConnected(false)
+                }
+            }
+             */
+            is ClientVersionUpdatedEvent -> {
+                if (e.contactId == _privateGroupId.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/privategroups/ThreadedText.kt b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedText.kt
new file mode 100644
index 0000000000000000000000000000000000000000..4b5eabad729c1f2dcaabae2ab265ee8870a5dc94
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/privategroups/ThreadedText.kt
@@ -0,0 +1,58 @@
+package org.briarproject.briar.desktop.privategroups
+
+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.shape.RoundedCornerShape
+import androidx.compose.material.Card
+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.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.InternationalizationUtils.i18n
+import org.briarproject.briar.desktop.utils.TimeUtils
+
+@Composable
+fun ThreadedText(m: ThreadedConversationMessageItem) {
+    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)
+
+    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.text!!, fontSize = 14.sp, modifier = Modifier.align(Alignment.Start))
+                    Row(modifier = Modifier.padding(top = 4.dp)) {
+                        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)
+                            val icon =
+                                if (m.isSeen) Icons.Filled.DoneAll // acknowledged
+                                else if (m.isSent) Icons.Filled.Done // sent
+                                else Icons.Filled.Schedule // waiting
+                            Icon(icon, i18n("access.message.sent"), modifier)
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/ui/Constants.kt b/src/main/kotlin/org/briarproject/briar/desktop/ui/Constants.kt
index dc533cbe859a56cfdc0459a0bc26b28e8dde0f3c..78598663ed696905cf5a8bb5d0987b0818a27b88 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/ui/Constants.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/ui/Constants.kt
@@ -6,4 +6,5 @@ object Constants {
 
     val HEADER_SIZE = 56.dp
     val CONTACTLIST_WIDTH = 275.dp
+    val PRIVATE_GROUP_LIST_WIDTH = CONTACTLIST_WIDTH
 }
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 7196cc58f1da1fb995a1da8b5b01d71d9dd8bbc4..d1334ba25660a1c6733ab93100e228c7db5f4166 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt
@@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
 import org.briarproject.briar.desktop.conversation.PrivateMessageScreen
 import org.briarproject.briar.desktop.navigation.BriarSidebar
 import org.briarproject.briar.desktop.navigation.SidebarViewModel
+import org.briarproject.briar.desktop.privategroups.PrivateGroupScreen
 import org.briarproject.briar.desktop.settings.PlaceHolderSettingsView
 import org.briarproject.briar.desktop.viewmodel.viewModel
 
@@ -28,6 +29,7 @@ fun MainScreen(
         VerticalDivider()
         when (viewModel.uiMode.value) {
             UiMode.CONTACTS -> PrivateMessageScreen()
+            UiMode.GROUPS -> PrivateGroupScreen()
             UiMode.SETTINGS -> PlaceHolderSettingsView(isDark, setDark)
             else -> UiPlaceholder()
         }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt
index a1edb2acb4a579258a2691fd054505ebac214ab4..a90748a289bfd34c97f9c22488e34cef6566deaf 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt
@@ -11,6 +11,8 @@ import org.briarproject.briar.desktop.introduction.IntroductionViewModel
 import org.briarproject.briar.desktop.login.LoginViewModel
 import org.briarproject.briar.desktop.login.RegistrationViewModel
 import org.briarproject.briar.desktop.navigation.SidebarViewModel
+import org.briarproject.briar.desktop.privategroups.PrivateGroupListViewModel
+import org.briarproject.briar.desktop.privategroups.ThreadedConversationViewModel
 import kotlin.reflect.KClass
 
 @Module
@@ -52,4 +54,14 @@ abstract class ViewModelModule {
     @IntoMap
     @ViewModelKey(IntroductionViewModel::class)
     abstract fun bindIntroductionViewModel(introductionViewModel: IntroductionViewModel): ViewModel
+
+    @Binds
+    @IntoMap
+    @ViewModelKey(PrivateGroupListViewModel::class)
+    abstract fun bindPrivateGroupListViewModel(privateGroupListViewModel: PrivateGroupListViewModel): ViewModel
+
+    @Binds
+    @IntoMap
+    @ViewModelKey(ThreadedConversationViewModel::class)
+    abstract fun bindThreadedConversationViewModel(threadedConversationViewModel: ThreadedConversationViewModel): ViewModel
 }