diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationList.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationList.kt
index ddc487fe0bcad4614588440dd74a5f410b3ece1e..23ebc06716f94016e5f1174fd912f61cb6e90890 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationList.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationList.kt
@@ -4,16 +4,19 @@ import androidx.compose.foundation.VerticalScrollbar
 import androidx.compose.foundation.background
 import androidx.compose.foundation.border
 import androidx.compose.foundation.layout.Box
+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.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
 import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.rememberScrollbarAdapter
 import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.Button
 import androidx.compose.material.FloatingActionButton
 import androidx.compose.material.Icon
 import androidx.compose.material.MaterialTheme
@@ -43,6 +46,7 @@ import org.briarproject.briar.desktop.ui.Loader
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
 import org.briarproject.briar.desktop.utils.PreviewUtils.preview
 import org.briarproject.briar.desktop.utils.replaceIfIndexed
+import org.briarproject.briar.desktop.viewmodel.SingleStateEvent
 import java.time.Instant
 
 fun main() = preview(
@@ -52,6 +56,8 @@ fun main() = preview(
     val numMessages = getIntParameter("num_messages")
     val initialFirstUnreadIndex = getIntParameter("first_unread_index")
 
+    val onMessageAddedToBottom = remember { SingleStateEvent<ConversationViewModel.MessageAddedType>() }
+
     // re-create messages and currentUnreadMessageInfo as soon as numMessages or initialFirstUnreadIndex change
     val loading by produceState(true, numMessages, initialFirstUnreadIndex) { value = true; delay(500); value = false }
 
@@ -89,20 +95,44 @@ fun main() = preview(
         return@preview
     }
 
-    ConversationList(
-        padding = PaddingValues(0.dp),
-        messages = messages,
-        initialFirstUnreadMessageIndex = initialFirstUnreadIndex,
-        currentUnreadMessagesInfo = currentUnreadMessagesInfo,
-        markMessagesRead = { lst ->
-            messages.replaceIfIndexed(
-                { idx, it -> idx in lst && !it.isRead },
-                { _, it -> it.markRead() }
-            )
-        },
-        respondToRequest = { _, _ -> },
-        deleteMessage = {}
-    )
+    Column {
+        Button(
+            onClick = {
+                messages.add(
+                    ConversationMessageItem(
+                        text = "Extra Message",
+                        id = MessageId(getRandomId()),
+                        groupId = GroupId(getRandomId()),
+                        time = Instant.now().toEpochMilli(),
+                        autoDeleteTimer = 0,
+                        isIncoming = true,
+                        isRead = false,
+                        isSent = false,
+                        isSeen = false
+                    )
+                )
+                onMessageAddedToBottom.emit(ConversationViewModel.MessageAddedType.INCOMING)
+            }
+        ) {
+            Text("Add new incoming message to bottom")
+        }
+
+        ConversationList(
+            padding = PaddingValues(0.dp),
+            messages = messages,
+            initialFirstUnreadMessageIndex = initialFirstUnreadIndex,
+            currentUnreadMessagesInfo = currentUnreadMessagesInfo,
+            onMessageAddedToBottom = onMessageAddedToBottom,
+            markMessagesRead = { lst ->
+                messages.replaceIfIndexed(
+                    { idx, it -> idx in lst && !it.isRead },
+                    { _, it -> it.markRead() }
+                )
+            },
+            respondToRequest = { _, _ -> },
+            deleteMessage = {},
+        )
+    }
 }
 
 @Composable
@@ -111,6 +141,7 @@ fun ConversationList(
     messages: List<ConversationItem>,
     initialFirstUnreadMessageIndex: Int,
     currentUnreadMessagesInfo: ConversationViewModel.UnreadMessagesInfo,
+    onMessageAddedToBottom: SingleStateEvent<ConversationViewModel.MessageAddedType>,
     markMessagesRead: (List<Int>) -> Unit,
     respondToRequest: (ConversationRequestItem, Boolean) -> Unit,
     deleteMessage: (MessageId) -> Unit,
@@ -129,19 +160,20 @@ fun ConversationList(
         LazyColumn(
             state = scrollState,
             contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp),
-            modifier = Modifier.fillMaxSize().padding(end = 12.dp)
+            modifier = Modifier.fillMaxSize().padding(end = 12.dp, top = 8.dp, bottom = 8.dp)
         ) {
             itemsIndexed(messages) { idx, m ->
                 if (idx == initialFirstUnreadMessageIndex) {
                     UnreadMessagesMarker()
                 }
                 when (m) {
-                    is ConversationMessageItem -> ConversationMessageItemView(m)
-                    is ConversationNoticeItem -> ConversationNoticeItemView(m)
+                    is ConversationMessageItem -> ConversationMessageItemView(m, deleteMessage)
+                    is ConversationNoticeItem -> ConversationNoticeItemView(m, deleteMessage)
                     is ConversationRequestItem ->
                         ConversationRequestItemView(
                             m,
                             onResponse = { accept -> respondToRequest(m, accept) },
+                            onDelete = deleteMessage
                         )
                 }
             }
@@ -178,6 +210,15 @@ fun ConversationList(
             }
         }
     }
+
+    onMessageAddedToBottom.react { type ->
+        // scroll to bottom for new *outgoing* message or if scroll position was at last message before
+        if (type == ConversationViewModel.MessageAddedType.OUTGOING || scrollState.isScrolledToPenultimate()) {
+            scope.launch {
+                scrollState.animateScrollToItem(messages.lastIndex)
+            }
+        }
+    }
 }
 
 @Composable
@@ -211,3 +252,9 @@ fun UnreadMessagesFAB(
         Modifier.align(Alignment.TopEnd).offset(3.dp, (-3).dp)
     )
 }
+
+fun LazyListState.isScrolledToPenultimate(): Boolean {
+    val last = layoutInfo.visibleItemsInfo.lastOrNull() ?: return false
+    return last.index == layoutInfo.totalItemsCount - 1 &&
+        last.offset == layoutInfo.viewportEndOffset
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationScreen.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationScreen.kt
index f7eff442c0f8830cbae81417243a7ac24bff9597..62993a1ed647111d9bdb880c0aebe1e6aa502f46 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationScreen.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationScreen.kt
@@ -78,6 +78,7 @@ fun ConversationScreen(
                     viewModel.messages,
                     viewModel.initialFirstUnreadMessageIndex.value,
                     viewModel.currentUnreadMessagesInfo.value,
+                    viewModel.onMessageAddedToBottom,
                     viewModel::markMessagesRead,
                     viewModel::respondToRequest,
                     viewModel::deleteMessage,
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt
index 969423ab5a62c6fcba468ca55aa675fa1d73c3af..f7054811ea0892f2bebfea68e8eb2794d0f8e9bc 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt
@@ -45,6 +45,7 @@ import org.briarproject.briar.desktop.utils.clearAndAddAll
 import org.briarproject.briar.desktop.utils.replaceIf
 import org.briarproject.briar.desktop.utils.replaceIfIndexed
 import org.briarproject.briar.desktop.viewmodel.EventListenerDbViewModel
+import org.briarproject.briar.desktop.viewmodel.SingleStateEvent
 import org.briarproject.briar.desktop.viewmodel.asList
 import org.briarproject.briar.desktop.viewmodel.asState
 import javax.inject.Inject
@@ -133,7 +134,7 @@ constructor(
                 )
                 val visitor = ConversationVisitor(contactItem.value!!.name, messagingManager, txn)
                 val msg = h.accept(visitor)!!
-                txn.attach { _messages.add(msg) }
+                txn.attach { addMessage(msg) }
             } catch (e: UnexpectedTimerException) {
                 // todo: handle this properly
                 LOG.warn(e) {}
@@ -162,6 +163,10 @@ constructor(
         val firstIndex: Int
     )
 
+    val onMessageAddedToBottom = SingleStateEvent<MessageAddedType>()
+
+    enum class MessageAddedType { OUTGOING, INCOMING }
+
     fun markMessagesRead(indices: List<Int>) {
         val id = _contactId.value!!
         val messages = _messages.toList()
@@ -259,7 +264,7 @@ constructor(
                     runOnDbThreadWithTransaction(true) { txn ->
                         val visitor = ConversationVisitor(contactItem.value!!.name, messagingManager, txn)
                         val msg = h.accept(visitor)!!
-                        txn.attach { _messages.add(msg) }
+                        txn.attach { addMessage(msg) }
                     }
                 }
             }
@@ -302,6 +307,14 @@ constructor(
         }
     }
 
+    private fun addMessage(msg: ConversationItem) {
+        // currently this method adds the message always at the end
+        // todo: instead we should check where to insert it to maintain the timely order
+        _messages.add(msg)
+        val type = if (msg.isIncoming) MessageAddedType.INCOMING else MessageAddedType.OUTGOING
+        onMessageAddedToBottom.emit(type)
+    }
+
     private fun markMessages(
         messageIds: Collection<MessageId>,
         sent: Boolean,
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/SingleStateEvent.kt b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/SingleStateEvent.kt
new file mode 100644
index 0000000000000000000000000000000000000000..72231fb872eeb9eac8c6d6dbe7a7261c71d1eb7d
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/SingleStateEvent.kt
@@ -0,0 +1,43 @@
+package org.briarproject.briar.desktop.viewmodel
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+
+/**
+ * An event to be emitted from anywhere and reacted to in a Composable.
+ * The class is backed by a [MutableState] and thus thread-safe.
+ * <p>
+ * Note that only one Composable will be able to react to the event,
+ * trying to react to it from multiple places is considered a bug.
+ * <p>
+ * As emitting one-time events instead of updating state goes against
+ * the declarative programming paradigm of Compose,
+ * only use this class if you are sure that you actually need it.
+ */
+class SingleStateEvent<T : Any> {
+    private var state = mutableStateOf<T?>(null)
+
+    /**
+     * Emit a new value of type [T] for this event.
+     */
+    fun emit(value: T) {
+        state.value = value
+    }
+
+    /**
+     * React to every new value of type [T] emitted through this event.
+     * Make sure to not react to the same event on multiple places.
+     */
+    @Composable
+    fun react(block: (T) -> Unit) {
+        LaunchedEffect(state.value) {
+            val value = state.value
+            if (value != null) {
+                block(value)
+                state.value = null
+            }
+        }
+    }
+}