diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/DesktopCoreModule.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/DesktopCoreModule.kt
index dc5a1fdd460da365a3aa487b36f98c436c469814..a1e3a66108fdf4ba536c231e42636a8c5760a43a 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/DesktopCoreModule.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/DesktopCoreModule.kt
@@ -20,10 +20,13 @@ package org.briarproject.briar.desktop
 
 import dagger.Module
 import dagger.Provides
+import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.asCoroutineDispatcher
 import kotlinx.coroutines.asExecutor
 import kotlinx.coroutines.swing.Swing
 import org.briarproject.bramble.account.AccountModule
+import org.briarproject.bramble.api.crypto.CryptoExecutor
 import org.briarproject.bramble.api.db.DatabaseConfig
 import org.briarproject.bramble.api.event.EventExecutor
 import org.briarproject.bramble.api.mailbox.MailboxDirectory
@@ -144,6 +147,12 @@ internal class DesktopCoreModule(
     @UiExecutor
     fun provideUiExecutor(): Executor = Dispatchers.Swing.asExecutor()
 
+    @Provides
+    @Singleton
+    @CryptoExecutor
+    fun provideCryptoDispatcher(@CryptoExecutor executor: Executor): CoroutineDispatcher =
+        executor.asCoroutineDispatcher()
+
     @Provides
     @Singleton
     fun provideBriarExecutors(briarExecutors: BriarExecutorsImpl): BriarExecutors = briarExecutors
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 3ae485767942513d04bd7c51f332bba2d6b2c2f8..8755aa850d50cbcab3ee722f07324ad19ac0fda9 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
@@ -22,29 +22,46 @@ import androidx.compose.runtime.State
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.mutableStateListOf
 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 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.lifecycle.LifecycleManager
 import org.briarproject.bramble.api.sync.GroupId
+import org.briarproject.bramble.api.sync.MessageId
 import org.briarproject.bramble.api.sync.event.GroupAddedEvent
 import org.briarproject.bramble.api.sync.event.GroupRemovedEvent
+import org.briarproject.bramble.api.system.Clock
 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.threading.UiExecutor
 import org.briarproject.briar.desktop.utils.clearAndAddAll
 import org.briarproject.briar.desktop.viewmodel.EventListenerDbViewModel
 import org.briarproject.briar.desktop.viewmodel.asState
+import java.lang.Long.max
 import javax.inject.Inject
 
 sealed class PostsState
 object Loading : PostsState()
-class Loaded(private val messageTree: MessageTreeImpl<ThreadItem>) : PostsState() {
+class Loaded(
+    val messageTree: MessageTreeImpl<ThreadItem>,
+    val scrollTo: MessageId? = null,
+) : PostsState() {
     val posts: MutableList<ThreadItem> get() = messageTree.depthFirstOrder()
 }
 
 class ForumsViewModel @Inject constructor(
     private val forumManager: ForumManager,
+    private val identityManager: IdentityManager,
+    private val clock: Clock,
+    @CryptoExecutor private val cryptoDispatcher: CoroutineDispatcher,
     briarExecutors: BriarExecutors,
     lifecycleManager: LifecycleManager,
     db: TransactionManager,
@@ -126,6 +143,36 @@ class ForumsViewModel @Inject constructor(
         _filterBy.value = filter
     }
 
+    @OptIn(DelicateCoroutinesApi::class)
+    fun createPost(groupItem: GroupItem, text: String, parentId: MessageId?) = GlobalScope.launch {
+        val author = runOnDbThread(false) { txn ->
+            identityManager.getLocalAuthor(txn)
+        }
+        val count = runOnDbThread(false) { txn ->
+            forumManager.getGroupCount(txn, groupItem.id)
+        }
+        val timestamp = max(count.latestMsgTime + 1, clock.currentTimeMillis())
+        val post = withContext(cryptoDispatcher) {
+            forumManager.createLocalPost(groupItem.id, text, timestamp, parentId, author)
+        }
+        runOnDbThread(false) { txn ->
+            val header = forumManager.addLocalPost(txn, post)
+            txn.attach {
+                val item = ForumPostItem(header, text)
+                addItem(item, item.id)
+            }
+        }
+    }
+
+    @UiExecutor
+    private fun addItem(item: ForumPostItem, 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)
+    }
+
     fun deleteGroup(groupItem: GroupItem) {
         forumManager.removeForum((groupItem as ForumsItem).forum)
     }
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 47ea90732eab3f4c404ad4c6ccd6696a3d8473b0..679d8a76f72a738733f0fb3cd792c1f8bc18c5d2 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
@@ -49,6 +49,7 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.text.style.TextOverflow.Companion.Ellipsis
 import androidx.compose.ui.unit.dp
+import org.briarproject.bramble.api.sync.MessageId
 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
@@ -61,13 +62,23 @@ fun GroupConversationScreen(
     groupItem: GroupItem,
     viewModel: ForumsViewModel = viewModel(),
 ) {
+    val selectedPost = remember { mutableStateOf<MessageId?>(null) }
     Scaffold(
         topBar = {
             GroupConversationHeader(groupItem) { viewModel.deleteGroup(groupItem) }
         },
         content = { padding ->
-            ThreadedConversationScreen(viewModel.posts.value)
+            ThreadedConversationScreen(
+                postsState = viewModel.posts.value,
+                selectedPost = selectedPost,
+                modifier = Modifier.padding(padding)
+            )
         },
+        bottomBar = {
+            GroupInputComposable(selectedPost) { text ->
+                viewModel.createPost(groupItem, text, selectedPost.value)
+            }
+        }
     )
 }
 
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupInputComposable.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupInputComposable.kt
new file mode 100644
index 0000000000000000000000000000000000000000..152c39e77b5d27cab8c007dcd8cc7c1becae2d9d
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupInputComposable.kt
@@ -0,0 +1,104 @@
+/*
+ * 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.layout.Column
+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.IconButton
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.material.TextField
+import androidx.compose.material.TextFieldDefaults
+import androidx.compose.material.TextFieldExt.moveFocusOnTab
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Send
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.PointerIconDefaults
+import androidx.compose.ui.input.pointer.pointerHoverIcon
+import androidx.compose.ui.unit.dp
+import org.briarproject.bramble.api.sync.MessageId
+import org.briarproject.briar.api.forum.ForumConstants.MAX_FORUM_POST_TEXT_LENGTH
+import org.briarproject.briar.desktop.theme.sendButton
+import org.briarproject.briar.desktop.ui.HorizontalDivider
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+
+@Composable
+@OptIn(ExperimentalComposeUiApi::class)
+fun GroupInputComposable(
+    selectedPost: State<MessageId?>,
+    onSend: (String) -> Unit,
+) {
+    val postText = rememberSaveable { mutableStateOf("") }
+    val onSendAction = {
+        val text = postText.value
+        if (text.isNotBlank() && postText.value.length <= MAX_FORUM_POST_TEXT_LENGTH) {
+            onSend(text)
+            postText.value = ""
+        }
+    }
+    Column {
+        HorizontalDivider()
+        TextField(
+            value = postText.value,
+            onValueChange = { postText.value = it },
+            onEnter = onSendAction,
+            maxLines = 10,
+            textStyle = MaterialTheme.typography.body1,
+            placeholder = {
+                Text(
+                    text = if (selectedPost.value == null) {
+                        i18n("forum.message.hint")
+                    } else {
+                        i18n("forum.message.replay.hint")
+                    },
+                    style = MaterialTheme.typography.body1,
+                )
+            },
+            modifier = Modifier
+                .fillMaxWidth()
+                .moveFocusOnTab(),
+            shape = RoundedCornerShape(0.dp),
+            colors = TextFieldDefaults.textFieldColors(
+                backgroundColor = MaterialTheme.colors.background,
+                focusedIndicatorColor = MaterialTheme.colors.background,
+                unfocusedIndicatorColor = MaterialTheme.colors.background
+            ),
+            trailingIcon = {
+                IconButton(
+                    icon = Icons.Filled.Send,
+                    iconTint = MaterialTheme.colors.sendButton,
+                    contentDescription = i18n("access.message.send"),
+                    onClick = onSendAction,
+                    modifier = Modifier
+                        .padding(4.dp)
+                        .size(32.dp)
+                        .pointerHoverIcon(PointerIconDefaults.Default),
+                )
+            }
+        )
+    }
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupsCard.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupsCard.kt
index c5f1fac5828be4489be6113b7dfcd8a07984c7f1..68f3a255c00d2a4d22de957c31674054dd3c89fc 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupsCard.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupsCard.kt
@@ -42,7 +42,7 @@ import org.briarproject.bramble.api.sync.GroupId
 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.utils.InternationalizationUtils
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18nP
 import org.briarproject.briar.desktop.utils.PreviewUtils
 import org.briarproject.briar.desktop.utils.TimeUtils.getFormattedTimestamp
@@ -78,8 +78,8 @@ fun GroupsCard(
             .clickable(onClick = { onGroupItemSelected(item) })
             .semantics {
                 contentDescription =
-                    if (selected) InternationalizationUtils.i18n("access.list.selected.yes")
-                    else InternationalizationUtils.i18n("access.list.selected.no")
+                    if (selected) i18n("access.list.selected.yes")
+                    else i18n("access.list.selected.no")
             },
         shape = RoundedCornerShape(0.dp),
         backgroundColor = if (selected) {
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
index 770e1ec6f5a2d1a73a886f2082abea88735ac020..679c9492f83d6f47d3126fc493ab144ae82e4a08 100644
--- 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
@@ -19,38 +19,56 @@
 package org.briarproject.briar.desktop.forums
 
 import androidx.compose.foundation.VerticalScrollbar
+import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
 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.selectable
 import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.material.MaterialTheme
 import androidx.compose.material.Text
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.ui.Alignment.Companion.CenterEnd
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import org.briarproject.bramble.api.sync.MessageId
 import org.briarproject.briar.desktop.forums.ThreadItem.Companion.UNDEFINED
+import org.briarproject.briar.desktop.theme.selectedCard
+import org.briarproject.briar.desktop.theme.surfaceVariant
 import org.briarproject.briar.desktop.ui.HorizontalDivider
 import org.briarproject.briar.desktop.ui.Loader
 
 @Composable
 fun ThreadedConversationScreen(
     postsState: PostsState,
+    selectedPost: MutableState<MessageId?>,
+    modifier: Modifier = Modifier
 ) = when (postsState) {
     Loading -> Loader()
     is Loaded -> {
         val scrollState = rememberLazyListState()
-        Box(modifier = Modifier.fillMaxSize()) {
+        val coroutineScope = rememberCoroutineScope()
+        // scroll to item if needed
+        if (postsState.scrollTo != null) coroutineScope.launch {
+            val index = postsState.posts.indexOfFirst { it.id == postsState.scrollTo }
+            if (index != -1) scrollState.scrollToItem(index, -50)
+        }
+        Box(modifier = modifier.fillMaxSize()) {
             LazyColumn(
                 state = scrollState,
                 modifier = Modifier.selectableGroup()
             ) {
                 items(postsState.posts) { item ->
-                    ThreadItemComposable(item)
+                    ThreadItemComposable(item, selectedPost)
                     HorizontalDivider()
                 }
             }
@@ -63,10 +81,22 @@ fun ThreadedConversationScreen(
 }
 
 @Composable
-fun ThreadItemComposable(item: ThreadItem) {
+fun ThreadItemComposable(item: ThreadItem, selectedPost: MutableState<MessageId?>) {
+    val isSelected = selectedPost.value == item.id
     Text(
-        text = item.text ?: "",
+        text = item.text,
         modifier = Modifier
+            .fillMaxWidth()
+            .background(
+                if (isSelected) {
+                    MaterialTheme.colors.selectedCard
+                } else {
+                    MaterialTheme.colors.surfaceVariant
+                }
+            ).selectable(
+                selected = isSelected,
+                onClick = { selectedPost.value = item.id }
+            )
             .padding(4.dp)
             .padding(
                 start = 4.dp +
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 73d439337dd4bc004c13cc29a5eb88f1c8f2a3ee..b82cb09a2fa6579730447c577bcf455dda25fd20 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
@@ -20,10 +20,13 @@ package org.briarproject.briar.desktop.viewmodel
 
 import mu.KotlinLogging
 import org.briarproject.bramble.api.db.DatabaseExecutor
+import org.briarproject.bramble.api.db.DbCallable
 import org.briarproject.bramble.api.db.Transaction
 import org.briarproject.bramble.api.db.TransactionManager
 import org.briarproject.bramble.api.lifecycle.LifecycleManager
 import org.briarproject.briar.desktop.threading.BriarExecutors
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
 
 abstract class DbViewModel(
     private val briarExecutors: BriarExecutors,
@@ -53,4 +56,14 @@ abstract class DbViewModel(
         readOnly: Boolean,
         task: (Transaction) -> Unit
     ) = briarExecutors.onDbThreadWithTransaction(readOnly, task)
+
+    protected suspend fun <T> runOnDbThread(
+        readOnly: Boolean,
+        task: (Transaction) -> T
+    ) = suspendCoroutine<T> { cont ->
+        briarExecutors.onDbThread {
+            val t = db.transactionWithResult(readOnly, DbCallable { txn -> task(txn) })
+            cont.resume(t)
+        }
+    }
 }
diff --git a/briar-desktop/src/main/resources/strings/BriarDesktop.properties b/briar-desktop/src/main/resources/strings/BriarDesktop.properties
index 78c9583eb9c43c53c2864f81fd5d2715ffc87f67..28a7b003cd1691f9bb38fa39dff7a96fd20b5427 100644
--- a/briar-desktop/src/main/resources/strings/BriarDesktop.properties
+++ b/briar-desktop/src/main/resources/strings/BriarDesktop.properties
@@ -114,6 +114,8 @@ forum.leave.title=Leave Forum
 forum.delete.dialog.title=Confirm Leaving Forum
 forum.delete.dialog.message=Are you sure that you want to leave this forum?\n\nAny contacts you\'ve shared this forum with might stop receiving updates.
 forum.delete.dialog.button=Leave
+forum.message.hint=New Post
+forum.message.replay.hint=New Reply
 group.card.no_posts=No posts
 group.card.posts={0, plural, one {{0} post} other {{0} posts}}