diff --git a/src/main/kotlin/androidx/compose/material/TextFieldExt.kt b/src/main/kotlin/androidx/compose/material/TextFieldExt.kt
new file mode 100644
index 0000000000000000000000000000000000000000..dcaad358c11f2c4d063a8f529e9b3bd2f2d4ff47
--- /dev/null
+++ b/src/main/kotlin/androidx/compose/material/TextFieldExt.kt
@@ -0,0 +1,104 @@
+package androidx.compose.material
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.shape.ZeroCornerSize
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEvent
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.isAltPressed
+import androidx.compose.ui.input.key.isCtrlPressed
+import androidx.compose.ui.input.key.isMetaPressed
+import androidx.compose.ui.input.key.isShiftPressed
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.input.VisualTransformation
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun TextField(
+    value: String,
+    onValueChange: (String) -> Unit,
+    modifier: Modifier = Modifier,
+    onEnter: () -> Unit = {},
+    enabled: Boolean = true,
+    readOnly: Boolean = false,
+    textStyle: TextStyle = LocalTextStyle.current,
+    label: @Composable (() -> Unit)? = null,
+    placeholder: @Composable (() -> Unit)? = null,
+    leadingIcon: @Composable (() -> Unit)? = null,
+    trailingIcon: @Composable (() -> Unit)? = null,
+    isError: Boolean = false,
+    visualTransformation: VisualTransformation = VisualTransformation.None,
+    singleLine: Boolean = false,
+    maxLines: Int = Int.MAX_VALUE,
+    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+    shape: Shape =
+        MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize),
+    colors: TextFieldColors = TextFieldDefaults.textFieldColors()
+) {
+    var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = value)) }
+    val textFieldValue = textFieldValueState.copy(text = value)
+
+    val modifier = Modifier.onPreviewKeyEvent {
+        if (it.type == KeyEventType.KeyDown && it.key == Key.Enter) {
+            if (it.isShiftPressed) {
+                textFieldValueState = textFieldValue.insertOrReplaceBy("\n")
+                onValueChange(textFieldValueState.text)
+            } else if (!it.isModifierKeyPressed) {
+                onEnter()
+            }
+            return@onPreviewKeyEvent true
+        }
+        false
+    }.then(modifier)
+
+    TextField(
+        enabled = enabled,
+        readOnly = readOnly,
+        value = textFieldValue,
+        onValueChange = {
+            textFieldValueState = it
+            if (value != it.text) {
+                onValueChange(it.text)
+            }
+        },
+        modifier = modifier,
+        singleLine = singleLine,
+        textStyle = textStyle,
+        label = label,
+        placeholder = placeholder,
+        leadingIcon = leadingIcon,
+        trailingIcon = trailingIcon,
+        isError = isError,
+        visualTransformation = visualTransformation,
+        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+        keyboardActions = KeyboardActions(onDone = { onEnter() }),
+        maxLines = maxLines,
+        interactionSource = interactionSource,
+        shape = shape,
+        colors = colors
+    )
+}
+
+val KeyEvent.isModifierKeyPressed: Boolean
+    get() = isShiftPressed || isAltPressed || isMetaPressed || isCtrlPressed
+
+fun TextFieldValue.insertOrReplaceBy(replacement: CharSequence) = this.copy(
+    text = text.replaceRange(selection.min, selection.max, replacement),
+    selection = TextRange(selection.min + 1, selection.min + 1)
+)
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationInput.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationInput.kt
index 8818ad76ef78de0a58575aeec1cfe817cc03475e..a19986aa1e2632ed8dbff48aa5952057063f55a3 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationInput.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationInput.kt
@@ -1,6 +1,5 @@
 package org.briarproject.briar.desktop.conversation
 
-import androidx.compose.desktop.ui.tooling.preview.Preview
 import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.fillMaxWidth
@@ -8,10 +7,11 @@ import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.AlertDialog
+import androidx.compose.material.ExperimentalMaterialApi
 import androidx.compose.material.Icon
 import androidx.compose.material.IconButton
 import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Surface
 import androidx.compose.material.Text
 import androidx.compose.material.TextField
 import androidx.compose.material.TextFieldDefaults
@@ -19,22 +19,32 @@ import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Send
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.text.TextStyle
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
-import org.briarproject.briar.desktop.theme.DarkColors
 import org.briarproject.briar.desktop.ui.HorizontalDivider
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+import org.briarproject.briar.desktop.utils.PreviewUtils.preview
 
-@Preview
-@Composable
-fun PreviewConversationInput() {
-    MaterialTheme(colors = DarkColors) {
-        Surface {
-            ConversationInput("Lorem ipsum.", {}, {})
-        }
+@OptIn(ExperimentalMaterialApi::class)
+fun main() = preview {
+    val (text, updateText) = remember { mutableStateOf("Lorem ipsum.") }
+    var dialogVisible by remember { mutableStateOf(false) }
+    var sentText by remember { mutableStateOf("") }
+    ConversationInput(text, updateText) { dialogVisible = true; sentText = text; updateText("") }
+
+    if (dialogVisible) {
+        AlertDialog(
+            onDismissRequest = { dialogVisible = false },
+            buttons = {},
+            text = { Text(sentText) },
+        )
     }
 }
 
@@ -45,6 +55,7 @@ fun ConversationInput(text: String, updateText: (String) -> Unit, onSend: () ->
         TextField(
             value = text,
             onValueChange = updateText,
+            onEnter = onSend,
             maxLines = 10,
             textStyle = TextStyle(fontSize = 16.sp, lineHeight = 16.sp),
             placeholder = { Text(i18n("conversation.message.new")) },