diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumListViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumListViewModel.kt
index 5efeec62135b6c776247089183b0f3cc15217fdf..f73e7dd194e9ec3345382b9229d99dd6c71eaeda 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumListViewModel.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumListViewModel.kt
@@ -30,6 +30,7 @@ import org.briarproject.bramble.api.sync.GroupId
 import org.briarproject.briar.api.client.PostHeader
 import org.briarproject.briar.api.forum.ForumManager
 import org.briarproject.briar.api.forum.event.ForumPostReceivedEvent
+import org.briarproject.briar.desktop.forums.conversation.ForumConversationViewModel
 import org.briarproject.briar.desktop.group.GroupListViewModel
 import org.briarproject.briar.desktop.threading.BriarExecutors
 import org.briarproject.briar.desktop.utils.removeFirst
@@ -39,7 +40,7 @@ import javax.inject.Inject
 class ForumListViewModel
 @Inject constructor(
     private val forumManager: ForumManager,
-    threadViewModel: ThreadedConversationViewModel,
+    threadViewModel: ForumConversationViewModel,
     briarExecutors: BriarExecutors,
     lifecycleManager: LifecycleManager,
     db: TransactionManager,
@@ -85,7 +86,7 @@ class ForumListViewModel
         }
 
     override fun addOwnMessage(header: PostHeader) {
-        selectedGroupId.value?.let { id -> updateItem(id) { it.updateOnPostReceived(header) } }
+        // no-op since GroupMessageAddedEvent is also sent on locally added message
     }
 
     private fun updateItem(groupId: GroupId, update: (ForumItem) -> ForumItem) =
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumStrings.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumStrings.kt
index 401007e7de157bd241b64996be529648852dd0c6..b9d7413254813505a206ccae442d67a02d2ef2d2 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumStrings.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumStrings.kt
@@ -47,6 +47,8 @@ object ForumStrings : GroupStrings(
     sharedWith = { total, online ->
         i18nF("forum.sharing.status.with", total, online)
     },
+    unreadJumpToPrevious = i18n("access.forums.jump_to_prev_unread"),
+    unreadJumpToNext = i18n("access.forums.jump_to_next_unread"),
     deleteDialogTitle = i18n("forum.delete.dialog.title"),
     deleteDialogMessage = i18n("forum.delete.dialog.message"),
     deleteDialogButton = i18n("forum.delete.dialog.button"),
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadedConversationViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadedConversationViewModel.kt
deleted file mode 100644
index 85168f8a8eabfcef4f5638faf001a29e371f35aa..0000000000000000000000000000000000000000
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadedConversationViewModel.kt
+++ /dev/null
@@ -1,199 +0,0 @@
-/*
- * Briar Desktop
- * Copyright (C) 2021-2023 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.runtime.mutableStateOf
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.DelicateCoroutinesApi
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import mu.KotlinLogging
-import org.briarproject.bramble.api.crypto.CryptoExecutor
-import org.briarproject.bramble.api.db.TransactionManager
-import org.briarproject.bramble.api.event.Event
-import org.briarproject.bramble.api.event.EventBus
-import org.briarproject.bramble.api.identity.IdentityManager
-import org.briarproject.bramble.api.identity.LocalAuthor
-import org.briarproject.bramble.api.lifecycle.LifecycleManager
-import org.briarproject.bramble.api.sync.GroupId
-import org.briarproject.bramble.api.sync.MessageId
-import org.briarproject.bramble.api.system.Clock
-import org.briarproject.briar.api.client.MessageTracker
-import org.briarproject.briar.api.forum.ForumManager
-import org.briarproject.briar.api.forum.ForumPostHeader
-import org.briarproject.briar.api.forum.event.ForumPostReceivedEvent
-import org.briarproject.briar.client.MessageTreeImpl
-import org.briarproject.briar.desktop.forums.conversation.ForumPostItem
-import org.briarproject.briar.desktop.forums.sharing.ForumSharingViewModel
-import org.briarproject.briar.desktop.group.GroupItem
-import org.briarproject.briar.desktop.group.conversation.ThreadItem
-import org.briarproject.briar.desktop.threading.BriarExecutors
-import org.briarproject.briar.desktop.threading.UiExecutor
-import org.briarproject.briar.desktop.viewmodel.EventListenerDbViewModel
-import org.briarproject.briar.desktop.viewmodel.asState
-import java.lang.Long.max
-import javax.inject.Inject
-
-class ThreadedConversationViewModel @Inject constructor(
-    val forumSharingViewModel: ForumSharingViewModel,
-    private val forumManager: ForumManager,
-    private val identityManager: IdentityManager,
-    private val clock: Clock,
-    @CryptoExecutor private val cryptoDispatcher: CoroutineDispatcher,
-    briarExecutors: BriarExecutors,
-    lifecycleManager: LifecycleManager,
-    db: TransactionManager,
-    private val eventBus: EventBus,
-) : EventListenerDbViewModel(briarExecutors, lifecycleManager, db, eventBus) {
-
-    companion object {
-        private val LOG = KotlinLogging.logger {}
-    }
-
-    private val _groupItem = mutableStateOf<GroupItem?>(null)
-    val groupItem = _groupItem.asState()
-
-    private lateinit var onPostAdded: (header: ForumPostHeader) -> Unit
-
-    private val _posts = mutableStateOf<PostsState>(Loading)
-    val posts = _posts.asState()
-
-    private val _selectedPost = mutableStateOf<ThreadItem?>(null)
-    val selectedPost = _selectedPost.asState()
-
-    @UiExecutor
-    fun setGroupItem(groupItem: GroupItem, onPostAdded: (header: ForumPostHeader) -> Unit) {
-        this.onPostAdded = onPostAdded
-        _groupItem.value = groupItem
-        _selectedPost.value = null
-        forumSharingViewModel.setGroupId(groupItem.id)
-        loadPosts(groupItem.id)
-    }
-
-    @UiExecutor
-    override fun eventOccurred(e: Event) {
-        if (e is ForumPostReceivedEvent) {
-            if (e.groupId == _groupItem.value?.id) {
-                val item = ForumPostItem(e.header, e.text)
-                addItem(item, null)
-            }
-        }
-    }
-
-    override fun onInit() {
-        super.onInit()
-        forumSharingViewModel.onEnterComposition()
-    }
-
-    override fun onCleared() {
-        super.onCleared()
-        forumSharingViewModel.onExitComposition()
-    }
-
-    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)
-            }
-        }
-    }
-
-    @UiExecutor
-    fun selectPost(post: ThreadItem?) {
-        _selectedPost.value = post
-        if (post != null && !post.isRead) markPostRead(post.id)
-    }
-
-    @UiExecutor
-    @OptIn(DelicateCoroutinesApi::class)
-    fun createPost(text: String) = GlobalScope.launch {
-        val groupId = _groupItem.value?.id ?: return@launch
-        val parentId = _selectedPost.value?.id
-        val author = runOnDbThreadWithTransaction<LocalAuthor>(false) { txn ->
-            identityManager.getLocalAuthor(txn)
-        }
-        val count = runOnDbThreadWithTransaction<MessageTracker.GroupCount>(false) { txn ->
-            forumManager.getGroupCount(txn, groupId)
-        }
-        val timestamp = max(count.latestMsgTime + 1, clock.currentTimeMillis())
-        val post = withContext(cryptoDispatcher) {
-            forumManager.createLocalPost(groupId, text, timestamp, parentId, author)
-        }
-        runOnDbThreadWithTransaction(false) { txn ->
-            val header = forumManager.addLocalPost(txn, post)
-            txn.attach {
-                val item = ForumPostItem(header, text)
-                addItem(item, item.id)
-                onPostAdded(header)
-                // unselect post that we just replied to
-                if (parentId != null) {
-                    _selectedPost.value = null
-                }
-            }
-        }
-    }
-
-    @UiExecutor
-    private fun addItem(item: ThreadItem, scrollTo: MessageId? = null) {
-        // If items haven't loaded, we need to wait until they have.
-        // Since this was a R/W DB transaction, the load will pick up this item.
-        val tree = (posts.value as? Loaded)?.messageTree ?: return
-        tree.add(item)
-        _posts.value = Loaded(tree, scrollTo)
-    }
-
-    @UiExecutor
-    private fun markPostRead(id: MessageId) = markPostsRead(listOf(id))
-
-    @UiExecutor
-    fun markPostsRead(ids: List<MessageId>) {
-        // TODO messageTree.get(id) would be nice, but not in briar-core
-        val readIds = (posts.value as? Loaded)?.posts?.filter { item ->
-            !item.isRead && ids.contains(item.id)
-        }?.map { item ->
-            item.isRead = true
-            item.id
-        } ?: emptyList()
-
-        val groupId = _groupItem.value?.id
-        if (readIds.isNotEmpty() && groupId != null) {
-            runOnDbThread {
-                readIds.forEach { id ->
-                    forumManager.setReadFlag(groupId, id, true)
-                }
-            }
-            // we don't attach this to the transaction that actually changes the DB,
-            // but that should be fine for this purpose of just decrementing a counter
-            eventBus.broadcast(ForumPostReadEvent(groupId, readIds.size))
-            // TODO replace immutable ThreadItems instead to avoid recomposing whole list
-            val messageTree = (posts.value as? Loaded)?.messageTree ?: return
-            _posts.value = Loaded(messageTree)
-        }
-    }
-
-    fun deleteGroup() {
-        _groupItem.value?.let { forumManager.removeForum((it as ForumItem).forum) }
-    }
-}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/conversation/ForumConversationViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/conversation/ForumConversationViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f3d19e999fdcaf90a9809d19a0393531ddbe53e4
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/conversation/ForumConversationViewModel.kt
@@ -0,0 +1,123 @@
+/*
+ * Briar Desktop
+ * Copyright (C) 2021-2023 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.conversation
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import mu.KotlinLogging
+import org.briarproject.bramble.api.crypto.CryptoExecutor
+import org.briarproject.bramble.api.db.DatabaseExecutor
+import org.briarproject.bramble.api.db.Transaction
+import org.briarproject.bramble.api.db.TransactionManager
+import org.briarproject.bramble.api.event.Event
+import org.briarproject.bramble.api.event.EventBus
+import org.briarproject.bramble.api.identity.IdentityManager
+import org.briarproject.bramble.api.identity.LocalAuthor
+import org.briarproject.bramble.api.lifecycle.LifecycleManager
+import org.briarproject.bramble.api.sync.GroupId
+import org.briarproject.bramble.api.sync.MessageId
+import org.briarproject.bramble.api.system.Clock
+import org.briarproject.briar.api.client.MessageTracker
+import org.briarproject.briar.api.forum.ForumManager
+import org.briarproject.briar.api.forum.event.ForumPostReceivedEvent
+import org.briarproject.briar.desktop.forums.ForumItem
+import org.briarproject.briar.desktop.forums.sharing.ForumSharingViewModel
+import org.briarproject.briar.desktop.group.conversation.ThreadedConversationViewModel
+import org.briarproject.briar.desktop.threading.BriarExecutors
+import org.briarproject.briar.desktop.threading.UiExecutor
+import java.lang.Long.max
+import javax.inject.Inject
+
+class ForumConversationViewModel @Inject constructor(
+    forumSharingViewModel: ForumSharingViewModel,
+    private val forumManager: ForumManager,
+    private val identityManager: IdentityManager,
+    private val clock: Clock,
+    @CryptoExecutor private val cryptoDispatcher: CoroutineDispatcher,
+    briarExecutors: BriarExecutors,
+    lifecycleManager: LifecycleManager,
+    db: TransactionManager,
+    eventBus: EventBus,
+) : ThreadedConversationViewModel(
+    forumSharingViewModel,
+    briarExecutors,
+    lifecycleManager,
+    db,
+    eventBus
+) {
+
+    companion object {
+        private val LOG = KotlinLogging.logger {}
+    }
+
+    @UiExecutor
+    override fun eventOccurred(e: Event) {
+        if (e is ForumPostReceivedEvent) {
+            if (e.groupId == groupItem.value?.id) {
+                val item = ForumPostItem(e.header, e.text)
+                addItem(item, null)
+            }
+        }
+    }
+
+    override fun loadThreadItems(txn: Transaction, groupId: GroupId) =
+        forumManager.getPostHeaders(txn, groupId).map { header ->
+            ForumPostItem(header, forumManager.getPostText(txn, header.id))
+        }
+
+    @UiExecutor
+    @OptIn(DelicateCoroutinesApi::class)
+    override fun createThreadItem(text: String) = GlobalScope.launch {
+        val groupId = groupItem.value?.id ?: return@launch
+        val parentId = selectedThreadItem.value?.id
+        val author = runOnDbThreadWithTransaction<LocalAuthor>(false) { txn ->
+            identityManager.getLocalAuthor(txn)
+        }
+        val count = runOnDbThreadWithTransaction<MessageTracker.GroupCount>(false) { txn ->
+            forumManager.getGroupCount(txn, groupId)
+        }
+        val timestamp = max(count.latestMsgTime + 1, clock.currentTimeMillis())
+        val post = withContext(cryptoDispatcher) {
+            forumManager.createLocalPost(groupId, text, timestamp, parentId, author)
+        }
+        runOnDbThreadWithTransaction(false) { txn ->
+            val header = forumManager.addLocalPost(txn, post)
+            txn.attach {
+                val item = ForumPostItem(header, text)
+                addItem(item, item.id)
+                onThreadItemAdded(header)
+                // unselect post that we just replied to
+                if (parentId != null) {
+                    selectThreadItem(null)
+                }
+            }
+        }
+    }
+
+    @DatabaseExecutor
+    override fun markThreadItemRead(groupId: GroupId, id: MessageId) =
+        forumManager.setReadFlag(groupId, id, true)
+
+    override fun deleteGroup() {
+        groupItem.value?.let { forumManager.removeForum((it as ForumItem).forum) }
+    }
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/GroupListViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/GroupListViewModel.kt
index 4c01f085ab222e11040637fae0c2a7a797bdab90..19ba8c0b8e067a1d786d33690718741395f1cd64 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/GroupListViewModel.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/GroupListViewModel.kt
@@ -32,7 +32,7 @@ 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.client.PostHeader
-import org.briarproject.briar.desktop.forums.ThreadedConversationViewModel
+import org.briarproject.briar.desktop.group.conversation.ThreadedConversationViewModel
 import org.briarproject.briar.desktop.threading.BriarExecutors
 import org.briarproject.briar.desktop.utils.clearAndAddAll
 import org.briarproject.briar.desktop.viewmodel.EventListenerDbViewModel
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/GroupStrings.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/GroupStrings.kt
index 1c7a45f8bc8c62fd8f93f582d6c7950032b54807..3ed1451d5696b07a3e7da58371170b86e13bd053 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/GroupStrings.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/GroupStrings.kt
@@ -32,6 +32,8 @@ abstract class GroupStrings(
     val lastMessage: (String) -> String,
     val groupNameMaxLength: Int,
     val sharedWith: (total: Int, online: Int) -> String,
+    val unreadJumpToPrevious: String,
+    val unreadJumpToNext: String,
     // todo: will need to be different for private groups depending on creator or not
     val deleteDialogTitle: String,
     val deleteDialogMessage: String,
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/conversation/GroupConversationScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/conversation/GroupConversationScreen.kt
index e1e1c1fc5f9764cd714696b8fcd1a3556d10439f..c6708c2bbc3e5d8cfe6d242429eb3e0762b58f69 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/conversation/GroupConversationScreen.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/conversation/GroupConversationScreen.kt
@@ -51,8 +51,6 @@ import androidx.compose.ui.text.style.TextOverflow.Companion.Ellipsis
 import androidx.compose.ui.unit.dp
 import org.briarproject.briar.desktop.contact.ContactDropDown.State.CLOSED
 import org.briarproject.briar.desktop.contact.ContactDropDown.State.MAIN
-import org.briarproject.briar.desktop.forums.ThreadedConversationScreen
-import org.briarproject.briar.desktop.forums.ThreadedConversationViewModel
 import org.briarproject.briar.desktop.forums.sharing.ForumSharingViewModel
 import org.briarproject.briar.desktop.group.GroupCircle
 import org.briarproject.briar.desktop.group.GroupInputComposable
@@ -82,17 +80,18 @@ fun GroupConversationScreen(
         },
         content = { padding ->
             ThreadedConversationScreen(
-                postsState = viewModel.posts.value,
-                selectedPost = viewModel.selectedPost.value,
-                onPostSelected = viewModel::selectPost,
-                onPostsVisible = viewModel::markPostsRead,
+                strings = strings,
+                state = viewModel.state.value,
+                selectedThreadItem = viewModel.selectedThreadItem.value,
+                onThreadItemSelected = viewModel::selectThreadItem,
+                onThreadItemsVisible = viewModel::markThreadItemsRead,
                 modifier = Modifier.padding(padding)
             )
         },
         bottomBar = {
-            val onCloseReply = { viewModel.selectPost(null) }
-            GroupInputComposable(viewModel.selectedPost.value, onCloseReply) { text ->
-                viewModel.createPost(text)
+            val onCloseReply = { viewModel.selectThreadItem(null) }
+            GroupInputComposable(viewModel.selectedThreadItem.value, onCloseReply) { text ->
+                viewModel.createThreadItem(text)
             }
         }
     )
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/group/conversation/ThreadedConversationScreen.kt
similarity index 78%
rename from briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadedConversationScreen.kt
rename to briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/conversation/ThreadedConversationScreen.kt
index bb2e1db993adb516935d3edc665c5395c5000c0d..d1fc5090fadfc820cde9320fe9946fcdca95764f 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadedConversationScreen.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/conversation/ThreadedConversationScreen.kt
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-package org.briarproject.briar.desktop.forums
+package org.briarproject.briar.desktop.group.conversation
 
 import androidx.compose.foundation.VerticalScrollbar
 import androidx.compose.foundation.layout.Box
@@ -36,26 +36,25 @@ import androidx.compose.ui.unit.dp
 import kotlinx.coroutines.delay
 import org.briarproject.bramble.api.sync.MessageId
 import org.briarproject.briar.desktop.conversation.reallyVisibleItemsInfo
-import org.briarproject.briar.desktop.group.conversation.ThreadItem
-import org.briarproject.briar.desktop.group.conversation.ThreadItemView
-import org.briarproject.briar.desktop.group.conversation.getMaxNestingLevel
+import org.briarproject.briar.desktop.group.GroupStrings
 import org.briarproject.briar.desktop.ui.Loader
 import org.briarproject.briar.desktop.ui.isWindowFocused
 
 @Composable
 fun ThreadedConversationScreen(
-    postsState: PostsState,
-    selectedPost: ThreadItem?,
-    onPostSelected: (ThreadItem) -> Unit,
-    onPostsVisible: (List<MessageId>) -> Unit,
+    strings: GroupStrings,
+    state: ThreadedConversationScreenState,
+    selectedThreadItem: ThreadItem?,
+    onThreadItemSelected: (ThreadItem) -> Unit,
+    onThreadItemsVisible: (List<MessageId>) -> Unit,
     modifier: Modifier = Modifier,
-) = when (postsState) {
+) = when (state) {
     Loading -> Loader()
     is Loaded -> {
         val scrollState = rememberLazyListState()
         // scroll to item if needed
-        if (postsState.scrollTo != null) LaunchedEffect(postsState) {
-            val index = postsState.posts.indexOfFirst { it.id == postsState.scrollTo }
+        if (state.scrollTo != null) LaunchedEffect(state) {
+            val index = state.posts.indexOfFirst { it.id == state.scrollTo }
             if (index != -1) scrollState.scrollToItem(index, -50)
         }
         val maxNestingLevel = getMaxNestingLevel()
@@ -64,16 +63,16 @@ fun ThreadedConversationScreen(
                 state = scrollState,
                 modifier = Modifier.padding(end = 8.dp).selectableGroup()
             ) {
-                items(postsState.posts, key = { item -> item.id }) { item ->
+                items(state.posts, key = { item -> item.id }) { item ->
                     ThreadItemView(
                         item = item,
                         maxNestingLevel = maxNestingLevel,
-                        selectedPost = selectedPost,
-                        onPostSelected = onPostSelected,
+                        selectedPost = selectedThreadItem,
+                        onPostSelected = onThreadItemSelected,
                     )
                 }
             }
-            UnreadFabs(scrollState, postsState)
+            UnreadFabs(strings, scrollState, state)
             VerticalScrollbar(
                 adapter = rememberScrollbarAdapter(scrollState),
                 modifier = Modifier.align(CenterEnd).fillMaxHeight()
@@ -82,7 +81,7 @@ fun ThreadedConversationScreen(
                 // if Briar Desktop currently has focus,
                 // mark all posts visible on the screen as read after some delay
                 LaunchedEffect(
-                    postsState,
+                    state,
                     scrollState.firstVisibleItemIndex,
                     scrollState.firstVisibleItemScrollOffset
                 ) {
@@ -90,7 +89,7 @@ fun ThreadedConversationScreen(
                     val visibleMessageIds = scrollState.layoutInfo.reallyVisibleItemsInfo.map {
                         it.key as MessageId
                     }
-                    onPostsVisible(visibleMessageIds)
+                    onThreadItemsVisible(visibleMessageIds)
                 }
             }
         }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/PostsState.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/conversation/ThreadedConversationScreenState.kt
similarity index 91%
rename from briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/PostsState.kt
rename to briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/conversation/ThreadedConversationScreenState.kt
index d3fca2ceb66b1bff694b72a1951a09ad32a45ad2..1f2f870a7b0693af5f5eb1885206f0d552685c19 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/PostsState.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/conversation/ThreadedConversationScreenState.kt
@@ -16,19 +16,18 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-package org.briarproject.briar.desktop.forums
+package org.briarproject.briar.desktop.group.conversation
 
 import org.briarproject.bramble.api.sync.MessageId
 import org.briarproject.briar.client.MessageTreeImpl
-import org.briarproject.briar.desktop.group.conversation.ThreadItem
 import org.briarproject.briar.desktop.threading.UiExecutor
 
-sealed class PostsState
-object Loading : PostsState()
+sealed class ThreadedConversationScreenState
+object Loading : ThreadedConversationScreenState()
 class Loaded(
     val messageTree: MessageTreeImpl<ThreadItem>,
     val scrollTo: MessageId? = null,
-) : PostsState() {
+) : ThreadedConversationScreenState() {
     val posts: List<ThreadItem> = messageTree.depthFirstOrder()
 
     @UiExecutor
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/conversation/ThreadedConversationViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/conversation/ThreadedConversationViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1bbe194cf197776a5e4549f09f1b7ce175245502
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/conversation/ThreadedConversationViewModel.kt
@@ -0,0 +1,144 @@
+/*
+ * Briar Desktop
+ * Copyright (C) 2021-2023 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.group.conversation
+
+import androidx.compose.runtime.mutableStateOf
+import kotlinx.coroutines.Job
+import mu.KotlinLogging
+import org.briarproject.bramble.api.db.DatabaseExecutor
+import org.briarproject.bramble.api.db.Transaction
+import org.briarproject.bramble.api.db.TransactionManager
+import org.briarproject.bramble.api.event.EventBus
+import org.briarproject.bramble.api.lifecycle.LifecycleManager
+import org.briarproject.bramble.api.sync.GroupId
+import org.briarproject.bramble.api.sync.MessageId
+import org.briarproject.briar.api.client.PostHeader
+import org.briarproject.briar.client.MessageTreeImpl
+import org.briarproject.briar.desktop.forums.ForumPostReadEvent
+import org.briarproject.briar.desktop.forums.sharing.ForumSharingViewModel
+import org.briarproject.briar.desktop.group.GroupItem
+import org.briarproject.briar.desktop.threading.BriarExecutors
+import org.briarproject.briar.desktop.threading.UiExecutor
+import org.briarproject.briar.desktop.viewmodel.EventListenerDbViewModel
+import org.briarproject.briar.desktop.viewmodel.asState
+
+abstract class ThreadedConversationViewModel(
+    val forumSharingViewModel: ForumSharingViewModel,
+    briarExecutors: BriarExecutors,
+    lifecycleManager: LifecycleManager,
+    db: TransactionManager,
+    private val eventBus: EventBus,
+) : EventListenerDbViewModel(briarExecutors, lifecycleManager, db, eventBus) {
+
+    companion object {
+        private val LOG = KotlinLogging.logger {}
+    }
+
+    private val _groupItem = mutableStateOf<GroupItem?>(null)
+    val groupItem = _groupItem.asState()
+
+    protected lateinit var onThreadItemAdded: (header: PostHeader) -> Unit
+
+    private val _state = mutableStateOf<ThreadedConversationScreenState>(Loading)
+    val state = _state.asState()
+
+    private val _selectedThreadItem = mutableStateOf<ThreadItem?>(null)
+    val selectedThreadItem = _selectedThreadItem.asState()
+
+    @UiExecutor
+    fun setGroupItem(groupItem: GroupItem, onThreadItemAdded: (header: PostHeader) -> Unit) {
+        this.onThreadItemAdded = onThreadItemAdded
+        _groupItem.value = groupItem
+        _selectedThreadItem.value = null
+        forumSharingViewModel.setGroupId(groupItem.id)
+        loadThreadItems(groupItem.id)
+    }
+
+    override fun onInit() {
+        super.onInit()
+        forumSharingViewModel.onEnterComposition()
+    }
+
+    override fun onCleared() {
+        super.onCleared()
+        forumSharingViewModel.onExitComposition()
+    }
+
+    protected abstract fun loadThreadItems(txn: Transaction, groupId: GroupId): List<ThreadItem>
+
+    private fun loadThreadItems(groupId: GroupId) {
+        _state.value = Loading
+        runOnDbThreadWithTransaction(true) { txn ->
+            val items = loadThreadItems(txn, groupId)
+            val tree = MessageTreeImpl<ThreadItem>().apply { add(items) }
+            txn.attach {
+                _state.value = Loaded(tree)
+            }
+        }
+    }
+
+    @UiExecutor
+    fun selectThreadItem(item: ThreadItem?) {
+        _selectedThreadItem.value = item
+        if (item != null && !item.isRead) markThreadItemsRead(listOf(item.id))
+    }
+
+    @UiExecutor
+    abstract fun createThreadItem(text: String): Job
+
+    @UiExecutor
+    protected fun addItem(item: ThreadItem, scrollTo: MessageId? = null) {
+        // If items haven't loaded, we need to wait until they have.
+        // Since this was a R/W DB transaction, the load will pick up this item.
+        val tree = (state.value as? Loaded)?.messageTree ?: return
+        tree.add(item)
+        _state.value = Loaded(tree, scrollTo)
+    }
+
+    @DatabaseExecutor
+    abstract fun markThreadItemRead(groupId: GroupId, id: MessageId)
+
+    @UiExecutor
+    fun markThreadItemsRead(ids: List<MessageId>) {
+        // TODO messageTree.get(id) would be nice, but not in briar-core
+        val readIds = (state.value as? Loaded)?.posts?.filter { item ->
+            !item.isRead && ids.contains(item.id)
+        }?.map { item ->
+            item.isRead = true
+            item.id
+        } ?: emptyList()
+
+        val groupId = _groupItem.value?.id
+        if (readIds.isNotEmpty() && groupId != null) {
+            runOnDbThread {
+                readIds.forEach { id ->
+                    markThreadItemRead(groupId, id)
+                }
+            }
+            // we don't attach this to the transaction that actually changes the DB,
+            // but that should be fine for this purpose of just decrementing a counter
+            eventBus.broadcast(ForumPostReadEvent(groupId, readIds.size))
+            // TODO replace immutable ThreadItems instead to avoid recomposing whole list
+            val messageTree = (state.value as? Loaded)?.messageTree ?: return
+            _state.value = Loaded(messageTree)
+        }
+    }
+
+    abstract fun deleteGroup()
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/UnreadFabs.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/conversation/UnreadFabs.kt
similarity index 90%
rename from briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/UnreadFabs.kt
rename to briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/conversation/UnreadFabs.kt
index d9e4a3025d068d62d0b92d9e1dcf8f53b3416027..b3e1dc37100c10a3ab420e8f87033adb78f6d810 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/UnreadFabs.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/group/conversation/UnreadFabs.kt
@@ -1,6 +1,6 @@
 /*
  * Briar Desktop
- * Copyright (C) 2021-2022 The Briar Project
+ * Copyright (C) 2021-2023 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
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-package org.briarproject.briar.desktop.forums
+package org.briarproject.briar.desktop.group.conversation
 
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.foundation.layout.Box
@@ -38,13 +38,13 @@ import androidx.compose.ui.unit.dp
 import kotlinx.coroutines.launch
 import org.briarproject.briar.desktop.conversation.firstReallyVisibleItemIndex
 import org.briarproject.briar.desktop.conversation.lastReallyVisibleItemIndex
+import org.briarproject.briar.desktop.group.GroupStrings
 import org.briarproject.briar.desktop.theme.ChevronDown
 import org.briarproject.briar.desktop.theme.ChevronUp
 import org.briarproject.briar.desktop.ui.NumberBadge
-import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
 
 @Composable
-fun BoxScope.UnreadFabs(scrollState: LazyListState, postsState: Loaded) {
+fun BoxScope.UnreadFabs(strings: GroupStrings, scrollState: LazyListState, postsState: Loaded) {
     val coroutineScope = rememberCoroutineScope()
 
     // remember first really visible item index based on scroll offset
@@ -59,10 +59,10 @@ fun BoxScope.UnreadFabs(scrollState: LazyListState, postsState: Loaded) {
         visible = unreadInfo.numUnread > 0,
         modifier = Modifier.align(TopEnd).padding(16.dp),
     ) {
-        UnreadPostsFab(
+        UnreadFab(
             imageVector = Icons.Default.ChevronUp,
             numUnread = unreadInfo.numUnread,
-            contentDescription = i18n("access.forums.jump_to_prev_unread"),
+            contentDescription = strings.unreadJumpToPrevious,
             onClick = {
                 coroutineScope.launch {
                     if (unreadInfo.nextUnreadIndex != null) {
@@ -88,10 +88,10 @@ fun BoxScope.UnreadFabs(scrollState: LazyListState, postsState: Loaded) {
         visible = bottomUnreadInfo.numUnread > 0,
         modifier = Modifier.align(BottomEnd).padding(16.dp),
     ) {
-        UnreadPostsFab(
+        UnreadFab(
             imageVector = Icons.Default.ChevronDown,
             numUnread = bottomUnreadInfo.numUnread,
-            contentDescription = i18n("access.forums.jump_to_next_unread"),
+            contentDescription = strings.unreadJumpToNext,
             onClick = {
                 coroutineScope.launch {
                     if (bottomUnreadInfo.nextUnreadIndex != null) scrollState.animateScrollToItem(
@@ -106,7 +106,7 @@ fun BoxScope.UnreadFabs(scrollState: LazyListState, postsState: Loaded) {
 }
 
 @Composable
-fun UnreadPostsFab(
+private fun UnreadFab(
     imageVector: ImageVector,
     numUnread: Int,
     contentDescription: String,
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/PrivateGroupListViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/PrivateGroupListViewModel.kt
index c6310296e2a4e54c0069876cb7f6e56a39f4bcbf..8f72074eb07e4f8d920476e8fd72318f22467667 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/PrivateGroupListViewModel.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/PrivateGroupListViewModel.kt
@@ -34,8 +34,8 @@ import org.briarproject.briar.api.privategroup.GroupMessageFactory
 import org.briarproject.briar.api.privategroup.PrivateGroupFactory
 import org.briarproject.briar.api.privategroup.PrivateGroupManager
 import org.briarproject.briar.api.privategroup.event.GroupMessageAddedEvent
-import org.briarproject.briar.desktop.forums.ThreadedConversationViewModel
 import org.briarproject.briar.desktop.group.GroupListViewModel
+import org.briarproject.briar.desktop.privategroup.conversation.PrivateGroupConversationViewModel
 import org.briarproject.briar.desktop.threading.BriarExecutors
 import org.briarproject.briar.desktop.utils.removeFirst
 import org.briarproject.briar.desktop.utils.replaceFirst
@@ -48,7 +48,7 @@ class PrivateGroupListViewModel
     private val privateGroupManager: PrivateGroupManager,
     private val privateGroupFactory: PrivateGroupFactory,
     private val privateGroupMessageFactory: GroupMessageFactory,
-    threadViewModel: ThreadedConversationViewModel, // todo: subclass
+    threadViewModel: PrivateGroupConversationViewModel,
     briarExecutors: BriarExecutors,
     lifecycleManager: LifecycleManager,
     db: TransactionManager,
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/PrivateGroupStrings.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/PrivateGroupStrings.kt
index 6d8415f83943b187e45afaab13e332892ae321fc..dbd8c400683b026566aa83219c0081a2bee4b214 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/PrivateGroupStrings.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/PrivateGroupStrings.kt
@@ -48,6 +48,8 @@ object PrivateGroupStrings : GroupStrings(
     sharedWith = { total, online ->
         i18nF("forum.sharing.status.with", total, online)
     },
+    unreadJumpToPrevious = i18n("access.forums.jump_to_prev_unread"),
+    unreadJumpToNext = i18n("access.forums.jump_to_next_unread"),
     deleteDialogTitle = i18n("forum.delete.dialog.title"),
     deleteDialogMessage = i18n("forum.delete.dialog.message"),
     deleteDialogButton = i18n("forum.delete.dialog.button"),
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/conversation/PrivateGroupConversationViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/conversation/PrivateGroupConversationViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9ae61f1688a706b8d1e8bc7bf35393eaf675999f
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/conversation/PrivateGroupConversationViewModel.kt
@@ -0,0 +1,130 @@
+/*
+ * Briar Desktop
+ * Copyright (C) 2021-2023 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.privategroup.conversation
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import mu.KotlinLogging
+import org.briarproject.bramble.api.crypto.CryptoExecutor
+import org.briarproject.bramble.api.db.DatabaseExecutor
+import org.briarproject.bramble.api.db.Transaction
+import org.briarproject.bramble.api.db.TransactionManager
+import org.briarproject.bramble.api.event.Event
+import org.briarproject.bramble.api.event.EventBus
+import org.briarproject.bramble.api.identity.IdentityManager
+import org.briarproject.bramble.api.identity.LocalAuthor
+import org.briarproject.bramble.api.lifecycle.LifecycleManager
+import org.briarproject.bramble.api.sync.GroupId
+import org.briarproject.bramble.api.sync.MessageId
+import org.briarproject.bramble.api.system.Clock
+import org.briarproject.briar.api.client.MessageTracker
+import org.briarproject.briar.api.privategroup.GroupMessageFactory
+import org.briarproject.briar.api.privategroup.JoinMessageHeader
+import org.briarproject.briar.api.privategroup.PrivateGroupManager
+import org.briarproject.briar.desktop.forums.sharing.ForumSharingViewModel
+import org.briarproject.briar.desktop.group.conversation.ThreadedConversationViewModel
+import org.briarproject.briar.desktop.threading.BriarExecutors
+import org.briarproject.briar.desktop.threading.UiExecutor
+import java.lang.Long.max
+import javax.inject.Inject
+
+class PrivateGroupConversationViewModel @Inject constructor(
+    forumSharingViewModel: ForumSharingViewModel,
+    private val privateGroupManager: PrivateGroupManager,
+    private val privateGroupMessageFactory: GroupMessageFactory,
+    private val identityManager: IdentityManager,
+    private val clock: Clock,
+    @CryptoExecutor private val cryptoDispatcher: CoroutineDispatcher,
+    briarExecutors: BriarExecutors,
+    lifecycleManager: LifecycleManager,
+    db: TransactionManager,
+    eventBus: EventBus,
+) : ThreadedConversationViewModel(
+    forumSharingViewModel,
+    briarExecutors,
+    lifecycleManager,
+    db,
+    eventBus
+) {
+
+    companion object {
+        private val LOG = KotlinLogging.logger {}
+    }
+
+    @UiExecutor
+    override fun eventOccurred(e: Event) {
+        // todo
+        /*if (e is ForumPostReceivedEvent) {
+            if (e.groupId == groupItem.value?.id) {
+                val item = ForumPostItem(e.header, e.text)
+                addItem(item, null)
+            }
+        }*/
+    }
+
+    override fun loadThreadItems(txn: Transaction, groupId: GroupId) =
+        privateGroupManager.getHeaders(txn, groupId).map { header ->
+            PrivateGroupMessageItem(
+                header,
+                if (header !is JoinMessageHeader) privateGroupManager.getMessageText(txn, header.id)
+                else "" // todo
+            )
+        }
+
+    @UiExecutor
+    @OptIn(DelicateCoroutinesApi::class)
+    override fun createThreadItem(text: String) = GlobalScope.launch {
+        val groupId = groupItem.value?.id ?: return@launch
+        val parentId = selectedThreadItem.value?.id
+        val author = runOnDbThreadWithTransaction<LocalAuthor>(false) { txn ->
+            identityManager.getLocalAuthor(txn)
+        }
+        val previousMsgId = runOnDbThread<MessageId> { privateGroupManager.getPreviousMsgId(groupId) }
+        val count = runOnDbThreadWithTransaction<MessageTracker.GroupCount>(false) { txn ->
+            privateGroupManager.getGroupCount(txn, groupId)
+        }
+        val timestamp = max(count.latestMsgTime + 1, clock.currentTimeMillis())
+        val post = withContext(cryptoDispatcher) {
+            privateGroupMessageFactory.createGroupMessage(groupId, timestamp, parentId, author, text, previousMsgId)
+        }
+        runOnDbThreadWithTransaction(false) { txn ->
+            val header = privateGroupManager.addLocalMessage(txn, post)
+            txn.attach {
+                val item = PrivateGroupMessageItem(header, text)
+                addItem(item, item.id)
+                onThreadItemAdded(header)
+                // unselect post that we just replied to
+                if (parentId != null) {
+                    selectThreadItem(null)
+                }
+            }
+        }
+    }
+
+    @DatabaseExecutor
+    override fun markThreadItemRead(groupId: GroupId, id: MessageId) =
+        privateGroupManager.setReadFlag(groupId, id, true)
+
+    override fun deleteGroup() {
+        groupItem.value?.let { privateGroupManager.removePrivateGroup(it.id) }
+    }
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/conversation/PrivateGroupMessageItem.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/conversation/PrivateGroupMessageItem.kt
new file mode 100644
index 0000000000000000000000000000000000000000..aeed9c2a948a3cdfacab3a8263847cdb8303a7a3
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/privategroup/conversation/PrivateGroupMessageItem.kt
@@ -0,0 +1,34 @@
+/*
+ * Briar Desktop
+ * Copyright (C) 2023 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.privategroup.conversation
+
+import org.briarproject.briar.api.privategroup.GroupMessageHeader
+import org.briarproject.briar.desktop.group.conversation.ThreadItem
+import javax.annotation.concurrent.NotThreadSafe
+
+@NotThreadSafe
+class PrivateGroupMessageItem(h: GroupMessageHeader, text: String?) : ThreadItem(
+    messageId = h.id,
+    parentId = h.parentId,
+    text = text ?: "",
+    timestamp = h.timestamp,
+    author = h.author,
+    authorInfo = h.authorInfo,
+    isRead = h.isRead
+)
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threading/BriarExecutors.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threading/BriarExecutors.kt
index d1affd5700904b60e0c2a6bb9e6768400ab3f50d..6c5a98c48b796625e8342f4b70da1dc955d52067 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threading/BriarExecutors.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threading/BriarExecutors.kt
@@ -1,6 +1,6 @@
 /*
  * Briar Desktop
- * Copyright (C) 2021-2022 The Briar Project
+ * Copyright (C) 2021-2023 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
@@ -25,6 +25,8 @@ import org.briarproject.bramble.api.lifecycle.IoExecutor
 interface BriarExecutors {
     fun onDbThread(@DatabaseExecutor task: () -> Unit)
 
+    suspend fun <T> runOnDbThread(@DatabaseExecutor task: () -> T): T
+
     fun onDbThreadWithTransaction(
         readOnly: Boolean,
         @DatabaseExecutor task: (Transaction) -> Unit,
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threading/BriarExecutorsImpl.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threading/BriarExecutorsImpl.kt
index 28f36dbf3182386c2fbd8adbbca3dfaf9e1a8339..de0923ebec55afdf348890f73d397c8645ec928c 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threading/BriarExecutorsImpl.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threading/BriarExecutorsImpl.kt
@@ -1,6 +1,6 @@
 /*
  * Briar Desktop
- * Copyright (C) 2021-2022 The Briar Project
+ * Copyright (C) 2021-2023 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
@@ -57,6 +57,16 @@ constructor(
         }
     }
 
+    override suspend fun <T> runOnDbThread(@DatabaseExecutor task: () -> T) =
+        suspendCoroutine { cont ->
+            // The coroutine suspends until the DatabaseExecutor has finished the task
+            // and ended the transaction. It then resumes with the returned value.
+            onDbThread {
+                val t = task()
+                cont.resume(t)
+            }
+        }
+
     override fun onDbThreadWithTransaction(
         readOnly: Boolean,
         @DatabaseExecutor task: (Transaction) -> Unit,
@@ -80,7 +90,7 @@ constructor(
 
     override suspend fun <T> runOnDbThreadWithTransaction(
         readOnly: Boolean,
-        @DatabaseExecutor task: (Transaction) -> T
+        @DatabaseExecutor task: (Transaction) -> T,
     ) = suspendCoroutine<T> { cont ->
         // The coroutine suspends until the DatabaseExecutor has finished the task
         // and ended the transaction. It then resumes with the returned value.
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/DbViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/DbViewModel.kt
index a67eb885d68cfab93e5ca4edd636a8fc5420f901..62a7a7fb4ac14a7aadab92271f46b7140099eeef 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/DbViewModel.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/DbViewModel.kt
@@ -1,6 +1,6 @@
 /*
  * Briar Desktop
- * Copyright (C) 2021-2022 The Briar Project
+ * Copyright (C) 2021-2023 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
@@ -49,6 +49,14 @@ abstract class DbViewModel(
         task: (Transaction) -> Unit,
     ) = briarExecutors.onDbThreadWithTransaction(readOnly, task)
 
+    /**
+     * Waits for the DB to open and runs the given [task] on the [DatabaseExecutor],
+     * returning its result.
+     */
+    protected suspend fun <T> runOnDbThread(
+        @DatabaseExecutor task: () -> T,
+    ): T = briarExecutors.runOnDbThread(task)
+
     /**
      * Waits for the DB to open and runs the given [task] on the [DatabaseExecutor],
      * returning its result.