diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsViewModel.kt
index 3b8b043b71e0ddcd8b42ebd4e2a5aca9a64aa2fa..3ae485767942513d04bd7c51f332bba2d6b2c2f8 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsViewModel.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsViewModel.kt
@@ -30,15 +30,20 @@ import org.briarproject.bramble.api.sync.GroupId
 import org.briarproject.bramble.api.sync.event.GroupAddedEvent
 import org.briarproject.bramble.api.sync.event.GroupRemovedEvent
 import org.briarproject.briar.api.forum.ForumManager
+import org.briarproject.briar.client.MessageTreeImpl
 import org.briarproject.briar.desktop.threading.BriarExecutors
 import org.briarproject.briar.desktop.utils.clearAndAddAll
 import org.briarproject.briar.desktop.viewmodel.EventListenerDbViewModel
 import org.briarproject.briar.desktop.viewmodel.asState
 import javax.inject.Inject
 
-class ForumsViewModel
-@Inject
-constructor(
+sealed class PostsState
+object Loading : PostsState()
+class Loaded(private val messageTree: MessageTreeImpl<ThreadItem>) : PostsState() {
+    val posts: MutableList<ThreadItem> get() = messageTree.depthFirstOrder()
+}
+
+class ForumsViewModel @Inject constructor(
     private val forumManager: ForumManager,
     briarExecutors: BriarExecutors,
     lifecycleManager: LifecycleManager,
@@ -60,6 +65,9 @@ constructor(
     private val _filterBy = mutableStateOf("")
     val filterBy = _filterBy.asState()
 
+    private val _posts = mutableStateOf<PostsState>(Loading)
+    val posts: State<PostsState> = _posts
+
     override fun onInit() {
         super.onInit()
         loadGroups()
@@ -96,10 +104,24 @@ constructor(
 
     fun selectGroup(groupItem: GroupItem) {
         _selectedGroupItem.value = groupItem
+        loadPosts(groupItem.id)
     }
 
     fun isSelected(groupId: GroupId) = _selectedGroupItem.value?.id == groupId
 
+    private fun loadPosts(groupId: GroupId) {
+        _posts.value = Loading
+        runOnDbThreadWithTransaction(true) { txn ->
+            val items = forumManager.getPostHeaders(txn, groupId).map { header ->
+                ForumPostItem(header, forumManager.getPostText(txn, header.id))
+            }
+            val tree = MessageTreeImpl<ThreadItem>().apply { add(items) }
+            txn.attach {
+                _posts.value = Loaded(tree)
+            }
+        }
+    }
+
     fun setFilterBy(filter: String) {
         _filterBy.value = filter
     }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupConversationScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupConversationScreen.kt
index 1be3918079a1548a2f1e8d03329a27ed60d0f759..47ea90732eab3f4c404ad4c6ccd6696a3d8473b0 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupConversationScreen.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupConversationScreen.kt
@@ -53,7 +53,6 @@ import org.briarproject.briar.desktop.contact.ContactDropDown.State.CLOSED
 import org.briarproject.briar.desktop.contact.ContactDropDown.State.MAIN
 import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE
 import org.briarproject.briar.desktop.ui.HorizontalDivider
-import org.briarproject.briar.desktop.ui.UiPlaceholder
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
 import org.briarproject.briar.desktop.viewmodel.viewModel
 
@@ -67,8 +66,7 @@ fun GroupConversationScreen(
             GroupConversationHeader(groupItem) { viewModel.deleteGroup(groupItem) }
         },
         content = { padding ->
-            // Loader()
-            UiPlaceholder()
+            ThreadedConversationScreen(viewModel.posts.value)
         },
     )
 }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadItem.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadItem.kt
new file mode 100644
index 0000000000000000000000000000000000000000..bd1dc746ed71c64ae6408b379129eec083d693a3
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadItem.kt
@@ -0,0 +1,91 @@
+/*
+ * Briar Desktop
+ * Copyright (C) 2021-2022 The Briar Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package org.briarproject.briar.desktop.forums
+
+import org.briarproject.bramble.api.identity.Author
+import org.briarproject.bramble.api.sync.MessageId
+import org.briarproject.briar.api.client.MessageTree
+import org.briarproject.briar.api.forum.ForumPostHeader
+import org.briarproject.briar.api.identity.AuthorInfo
+import org.briarproject.briar.desktop.utils.UiUtils.getContactDisplayName
+import javax.annotation.concurrent.NotThreadSafe
+
+@NotThreadSafe
+class ForumPostItem(h: ForumPostHeader, text: String?) : ThreadItem(
+    messageId = h.id,
+    parentId = h.parentId,
+    text = text ?: "",
+    timestamp = h.timestamp,
+    author = h.author,
+    authorInfo = h.authorInfo,
+    isRead = h.isRead
+)
+
+@NotThreadSafe
+abstract class ThreadItem(
+    private val messageId: MessageId,
+    private val parentId: MessageId?,
+    val text: String,
+    private val timestamp: Long,
+    val author: Author,
+    val authorInfo: AuthorInfo,
+    var isRead: Boolean,
+) : MessageTree.MessageNode {
+
+    companion object {
+        const val UNDEFINED = -1
+    }
+
+    private var level: Int = UNDEFINED
+    var isHighlighted = false
+
+    fun getLevel(): Int {
+        return level
+    }
+
+    override fun getId(): MessageId {
+        return messageId
+    }
+
+    override fun getParentId(): MessageId? {
+        return parentId
+    }
+
+    override fun getTimestamp(): Long {
+        return timestamp
+    }
+
+    /**
+     * Returns the author's name, with an alias if one exists.
+     */
+    val authorName: String
+        get() = getContactDisplayName(author.name, authorInfo.alias)
+
+    override fun setLevel(level: Int) {
+        this.level = level
+    }
+
+    override fun hashCode(): Int {
+        return messageId.hashCode()
+    }
+
+    override fun equals(other: Any?): Boolean {
+        return other is ThreadItem && messageId == other.messageId
+    }
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadedConversationScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadedConversationScreen.kt
new file mode 100644
index 0000000000000000000000000000000000000000..770e1ec6f5a2d1a73a886f2082abea88735ac020
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadedConversationScreen.kt
@@ -0,0 +1,76 @@
+/*
+ * Briar Desktop
+ * Copyright (C) 2021-2022 The Briar Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package org.briarproject.briar.desktop.forums
+
+import androidx.compose.foundation.VerticalScrollbar
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.rememberScrollbarAdapter
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment.Companion.CenterEnd
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.briarproject.briar.desktop.forums.ThreadItem.Companion.UNDEFINED
+import org.briarproject.briar.desktop.ui.HorizontalDivider
+import org.briarproject.briar.desktop.ui.Loader
+
+@Composable
+fun ThreadedConversationScreen(
+    postsState: PostsState,
+) = when (postsState) {
+    Loading -> Loader()
+    is Loaded -> {
+        val scrollState = rememberLazyListState()
+        Box(modifier = Modifier.fillMaxSize()) {
+            LazyColumn(
+                state = scrollState,
+                modifier = Modifier.selectableGroup()
+            ) {
+                items(postsState.posts) { item ->
+                    ThreadItemComposable(item)
+                    HorizontalDivider()
+                }
+            }
+            VerticalScrollbar(
+                adapter = rememberScrollbarAdapter(scrollState),
+                modifier = Modifier.align(CenterEnd).fillMaxHeight()
+            )
+        }
+    }
+}
+
+@Composable
+fun ThreadItemComposable(item: ThreadItem) {
+    Text(
+        text = item.text ?: "",
+        modifier = Modifier
+            .padding(4.dp)
+            .padding(
+                start = 4.dp +
+                    if (item.getLevel() == UNDEFINED) 0.dp else (item.getLevel() * 8).dp
+            ),
+    )
+}
diff --git a/briar-desktop/src/test/kotlin/org/briarproject/briar/desktop/TestRandomConversations.kt b/briar-desktop/src/test/kotlin/org/briarproject/briar/desktop/TestRandomConversations.kt
index 652321be9ce5ca56edb62175beac72d321cb75ed..d832d410a2aff49ba483f2a64481867a4fbbef04 100644
--- a/briar-desktop/src/test/kotlin/org/briarproject/briar/desktop/TestRandomConversations.kt
+++ b/briar-desktop/src/test/kotlin/org/briarproject/briar/desktop/TestRandomConversations.kt
@@ -19,5 +19,5 @@
 package org.briarproject.briar.desktop
 
 fun main() = RunWithTemporaryAccount {
-    getTestDataCreator().createTestData(5, 20, 50, 4, 10, 10)
+    getTestDataCreator().createTestData(5, 20, 50, 4, 10, 42)
 }.run()