diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationItem.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationItem.kt
index bc7b2959e2427b8dcd169ad2ce2b4a926ed2eec8..fac09ce5d9c2b9dc2aa83ee380378259826de1b2 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationItem.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationItem.kt
@@ -4,6 +4,7 @@ import org.briarproject.bramble.api.sync.GroupId
 import org.briarproject.bramble.api.sync.MessageId
 
 sealed class ConversationItem {
+    abstract var text: String?
     abstract val id: MessageId
     abstract val groupId: GroupId
     abstract val time: Long
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageItem.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageItem.kt
index 2d48f76bb69eae1a3b8a75ab7b8683008c927436..24eb5f486fe4b248dbb6374d4612f39308c7e073 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageItem.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationMessageItem.kt
@@ -5,7 +5,7 @@ import org.briarproject.bramble.api.sync.MessageId
 import org.briarproject.briar.api.conversation.ConversationMessageHeader
 
 data class ConversationMessageItem(
-    var text: String? = null,
+    override var text: String? = null,
     override val id: MessageId,
     override val groupId: GroupId,
     override val time: Long,
@@ -19,23 +19,20 @@ data class ConversationMessageItem(
     // val attachments: List<AttachmentItem>
 ) : ConversationItem() {
 
-    constructor(h: ConversationMessageHeader) :
-        this(
-            id = h.id,
-            groupId = h.groupId,
-            time = h.timestamp,
-            autoDeleteTimer = h.autoDeleteTimer,
-            isRead = h.isRead,
-            isSent = h.isSent,
-            isSeen = h.isSeen,
-            isIncoming = !h.isLocal,
-        )
+    constructor(h: ConversationMessageHeader) : this(
+        id = h.id,
+        groupId = h.groupId,
+        time = h.timestamp,
+        autoDeleteTimer = h.autoDeleteTimer,
+        isRead = h.isRead,
+        isSent = h.isSent,
+        isSeen = h.isSeen,
+        isIncoming = !h.isLocal,
+    )
 
-    override fun mark(sent: Boolean, seen: Boolean): ConversationItem {
-        return copy(isSent = sent, isSeen = seen)
-    }
+    override fun mark(sent: Boolean, seen: Boolean): ConversationItem =
+        copy(isSent = sent, isSeen = seen)
 
-    override fun markRead(): ConversationItem {
-        return copy(isRead = true)
-    }
+    override fun markRead(): ConversationItem =
+        copy(isRead = true)
 }
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationNoticeItem.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationNoticeItem.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a3858be9257e88dd27f9abf4ed04c2d6e9faa3c7
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationNoticeItem.kt
@@ -0,0 +1,52 @@
+package org.briarproject.briar.desktop.conversation
+
+import org.briarproject.bramble.api.sync.GroupId
+import org.briarproject.bramble.api.sync.MessageId
+import org.briarproject.briar.api.conversation.ConversationRequest
+import org.briarproject.briar.api.conversation.ConversationResponse
+
+data class ConversationNoticeItem(
+    val msgText: String,
+    override var text: String?,
+    override val id: MessageId,
+    override val groupId: GroupId,
+    override val time: Long,
+    override val autoDeleteTimer: Long,
+    override val isIncoming: Boolean,
+    override var isRead: Boolean,
+    override var isSent: Boolean,
+    override var isSeen: Boolean,
+) : ConversationItem() {
+
+    constructor(msgText: String, r: ConversationRequest<*>) : this(
+        msgText = msgText,
+        text = r.text,
+        id = r.id,
+        groupId = r.groupId,
+        time = r.timestamp,
+        autoDeleteTimer = r.autoDeleteTimer,
+        isRead = r.isRead,
+        isSent = r.isSent,
+        isSeen = r.isSeen,
+        isIncoming = !r.isLocal,
+    )
+
+    constructor(msgText: String, r: ConversationResponse) : this(
+        msgText = msgText,
+        text = null,
+        id = r.id,
+        groupId = r.groupId,
+        time = r.timestamp,
+        autoDeleteTimer = r.autoDeleteTimer,
+        isRead = r.isRead,
+        isSent = r.isSent,
+        isSeen = r.isSeen,
+        isIncoming = !r.isLocal,
+    )
+
+    override fun mark(sent: Boolean, seen: Boolean): ConversationItem =
+        copy(isSent = sent, isSeen = seen)
+
+    override fun markRead(): ConversationItem =
+        copy(isRead = true)
+}
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationRequestItem.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationRequestItem.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a960f2988fbf58486d35157c5ce61b8410f5c3e9
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationRequestItem.kt
@@ -0,0 +1,58 @@
+package org.briarproject.briar.desktop.conversation
+
+import org.briarproject.bramble.api.sync.GroupId
+import org.briarproject.bramble.api.sync.MessageId
+import org.briarproject.briar.api.client.SessionId
+import org.briarproject.briar.api.conversation.ConversationRequest
+import org.briarproject.briar.api.sharing.InvitationRequest
+import org.briarproject.briar.api.sharing.Shareable
+
+data class ConversationRequestItem(
+    val requestedGroupId: GroupId?,
+    val requestType: RequestType,
+    val sessionId: SessionId,
+    val answered: Boolean,
+    val msgText: String,
+    override var text: String?,
+    override val id: MessageId,
+    override val groupId: GroupId,
+    override val time: Long,
+    override val autoDeleteTimer: Long,
+    override val isIncoming: Boolean,
+    override var isRead: Boolean,
+    override var isSent: Boolean,
+    override var isSeen: Boolean,
+) : ConversationItem() {
+
+    enum class RequestType {
+        INTRODUCTION, FORUM, BLOG, GROUP
+    }
+
+    constructor(msgText: String, type: RequestType, r: ConversationRequest<*>) : this(
+        requestedGroupId = if (r is InvitationRequest) (r.nameable as Shareable).id else null,
+        requestType = type,
+        sessionId = r.sessionId,
+        answered = r.wasAnswered(),
+        msgText = msgText,
+        text = r.text,
+        id = r.id,
+        groupId = r.groupId,
+        time = r.timestamp,
+        autoDeleteTimer = r.autoDeleteTimer,
+        isRead = r.isRead,
+        isSent = r.isSent,
+        isSeen = r.isSeen,
+        isIncoming = !r.isLocal,
+    )
+
+    val canBeOpened = requestedGroupId != null
+
+    override fun mark(sent: Boolean, seen: Boolean): ConversationItem =
+        copy(isSent = sent, isSeen = seen)
+
+    override fun markRead(): ConversationItem =
+        copy(isRead = true)
+
+    fun markAnswered(): ConversationItem =
+        copy(answered = true)
+}
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 0b3d1898ba2b9dda9929667a4fc7b245ac1cfe73..bde1041e197380018711a1741127dbb4ef920319 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationScreen.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationScreen.kt
@@ -21,6 +21,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.MaterialTheme
 import androidx.compose.material.Scaffold
+import androidx.compose.material.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
@@ -79,8 +80,10 @@ fun ConversationScreen(
                     modifier = Modifier.padding(padding).fillMaxHeight()
                 ) {
                     items(viewModel.messages) { m ->
-                        if (m is ConversationMessageItem)
-                            TextBubble(m)
+                        when (m) {
+                            is ConversationMessageItem -> TextBubble(m)
+                            else -> Text("Placeholder for something else.")
+                        }
                     }
                 }
             },
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 b4356dc1cac2ec71607efd126e3618d0591fab00..d5da4f1587abe48fff4a075dc4dc1c31adc1febc 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt
@@ -61,6 +61,14 @@ constructor(
         private val LOG = KotlinLogging.logger {}
     }
 
+    private val conversationVisitor = derivedStateOf {
+        val c = _contactItem.value
+        if (c != null)
+            ConversationVisitor(c.name, ::loadMessageText)
+        else
+            null
+    }
+
     private val _contactId = mutableStateOf<ContactId?>(null)
     private val _contactItem = mutableStateOf<ContactItem?>(null)
     private val _messages = mutableStateListOf<ConversationItem>()
@@ -114,9 +122,8 @@ constructor(
                     m.hasText(), m.attachmentHeaders,
                     m.autoDeleteTimer
                 )
-                val msg = messageHeaderToItem(txn, h)
                 txn.attach {
-                    _messages.add(0, msg)
+                    _messages.add(0, h.accept(conversationVisitor.value)!!)
                 }
             } catch (e: UnexpectedTimerException) {
                 // todo: handle this properly
@@ -185,7 +192,7 @@ constructor(
             val sorted = headers.sortedByDescending { it.timestamp }
             // todo: use ConversationVisitor to also display Request and Notice Messages
             start = LogUtils.now()
-            val messages = sorted.filterIsInstance<PrivateMessageHeader>().map { messageHeaderToItem(txn, it) }
+            val messages = sorted.map { h -> h.accept(conversationVisitor.value)!! }
             LOG.logDuration("Loading messages", start)
             txn.attach { _messages.clearAndAddAll(messages) }
         } catch (e: NoSuchContactException) {
@@ -194,20 +201,9 @@ constructor(
         }
     }
 
-    private fun messageHeaderToItem(txn: Transaction, h: PrivateMessageHeader): ConversationMessageItem {
-        // todo: use ConversationVisitor instead and support other MessageHeader
-        val item = ConversationMessageItem(h)
-        if (h.hasText()) {
-            item.text = loadMessageText(txn, h.id)
-        } else {
-            LOG.warn { "private message without text" }
-        }
-        return item
-    }
-
-    private fun loadMessageText(txn: Transaction, m: MessageId): String? {
+    private fun loadMessageText(m: MessageId): String? {
         try {
-            return messagingManager.getMessageText(txn, m)
+            return messagingManager.getMessageText(m) // todo: use transactional API call somehow
         } catch (e: DbException) {
             LOG.warn(e) {}
         }
@@ -226,13 +222,8 @@ constructor(
                 if (e.contactId == _contactId.value) {
                     LOG.info("Message received, adding")
                     val h = e.messageHeader
-                    if (h is PrivateMessageHeader) {
-                        // insert at start of list according to descending sort order
-                        runOnDbThreadWithTransaction(true) { txn ->
-                            val msg = messageHeaderToItem(txn, h)
-                            txn.attach { _messages.add(0, msg) }
-                        }
-                    }
+                    // insert at start of list according to descending sort order
+                    _messages.add(0, h.accept(conversationVisitor.value)!!)
                 }
             }
             is MessagesSentEvent -> {
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationVisitor.kt b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationVisitor.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a17f736652d198c8c25d08e1eaf0747ba07498b0
--- /dev/null
+++ b/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationVisitor.kt
@@ -0,0 +1,198 @@
+package org.briarproject.briar.desktop.conversation
+
+import mu.KotlinLogging
+import org.briarproject.bramble.api.db.DatabaseExecutor
+import org.briarproject.bramble.api.db.Transaction
+import org.briarproject.bramble.api.sync.MessageId
+import org.briarproject.briar.api.blog.BlogInvitationRequest
+import org.briarproject.briar.api.blog.BlogInvitationResponse
+import org.briarproject.briar.api.conversation.ConversationMessageVisitor
+import org.briarproject.briar.api.forum.ForumInvitationRequest
+import org.briarproject.briar.api.forum.ForumInvitationResponse
+import org.briarproject.briar.api.introduction.IntroductionRequest
+import org.briarproject.briar.api.introduction.IntroductionResponse
+import org.briarproject.briar.api.messaging.PrivateMessageHeader
+import org.briarproject.briar.api.privategroup.invitation.GroupInvitationRequest
+import org.briarproject.briar.api.privategroup.invitation.GroupInvitationResponse
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18nF
+
+internal class ConversationVisitor(
+    private val contactName: String,
+    private val loadMessageText: (MessageId) -> String?
+) : ConversationMessageVisitor<ConversationItem?> {
+
+    companion object {
+        private val LOG = KotlinLogging.logger {}
+    }
+
+    @DatabaseExecutor
+    override fun visitPrivateMessageHeader(h: PrivateMessageHeader): ConversationItem {
+        val item = ConversationMessageItem(h)
+        if (h.hasText()) {
+            item.text = loadMessageText(h.id)
+        } else {
+            LOG.warn { "private message without text" }
+        }
+        return item
+    }
+
+    override fun visitBlogInvitationRequest(r: BlogInvitationRequest): ConversationItem {
+
+        return if (r.isLocal)
+            ConversationNoticeItem(
+                i18nF("blogs_sharing_invitation_sent", r.name, contactName),
+                r
+            )
+        else
+            ConversationRequestItem(
+                i18nF("blogs_sharing_invitation_received", contactName, r.name),
+                ConversationRequestItem.RequestType.BLOG, r
+            )
+    }
+
+    override fun visitBlogInvitationResponse(r: BlogInvitationResponse): ConversationItem {
+        return if (r.isLocal) {
+            val text = when {
+                r.wasAccepted() ->
+                    i18nF("blogs_sharing_response_accepted_sent", contactName)
+                r.isAutoDecline ->
+                    i18nF("blogs_sharing_response_declined_auto", contactName)
+                else ->
+                    i18nF("blogs_sharing_response_declined_sent", contactName)
+            }
+            ConversationNoticeItem(text, r)
+        } else {
+            val text = when {
+                r.wasAccepted() ->
+                    i18nF("blogs_sharing_response_accepted_received", contactName)
+                else ->
+                    i18nF("blogs_sharing_response_declined_received", contactName)
+            }
+            ConversationNoticeItem(text, r)
+        }
+    }
+
+    override fun visitForumInvitationRequest(r: ForumInvitationRequest): ConversationItem {
+        return if (r.isLocal)
+            ConversationNoticeItem(
+                i18nF("forum_invitation_sent", r.name, contactName),
+                r
+            )
+        else
+            ConversationRequestItem(
+                i18nF("forum_invitation_received", contactName, r.name),
+                ConversationRequestItem.RequestType.FORUM, r
+            )
+    }
+
+    override fun visitForumInvitationResponse(r: ForumInvitationResponse): ConversationItem {
+        return if (r.isLocal) {
+            val text = when {
+                r.wasAccepted() ->
+                    i18nF("forum_invitation_response_accepted_sent", contactName)
+                r.isAutoDecline ->
+                    i18nF("forum_invitation_response_declined_auto", contactName)
+                else ->
+                    i18nF("forum_invitation_response_declined_sent", contactName)
+            }
+            ConversationNoticeItem(text, r)
+        } else {
+            val text = when {
+                r.wasAccepted() ->
+                    i18nF("forum_invitation_response_accepted_received", contactName)
+                else ->
+                    i18nF("forum_invitation_response_declined_received", contactName)
+            }
+            ConversationNoticeItem(text, r)
+        }
+    }
+
+    override fun visitGroupInvitationRequest(r: GroupInvitationRequest): ConversationItem {
+        return if (r.isLocal)
+            ConversationNoticeItem(
+                i18nF("groups_invitations_invitation_sent", contactName, r.name),
+                r
+            )
+        else
+            ConversationRequestItem(
+                i18nF("groups_invitations_invitation_received", contactName, r.name),
+                ConversationRequestItem.RequestType.GROUP, r
+            )
+    }
+
+    override fun visitGroupInvitationResponse(r: GroupInvitationResponse): ConversationItem {
+        return if (r.isLocal) {
+            val text = when {
+                r.wasAccepted() ->
+                    i18nF("groups_invitations_response_accepted_sent", contactName)
+                r.isAutoDecline ->
+                    i18nF("groups_invitations_response_declined_auto", contactName)
+                else ->
+                    i18nF("groups_invitations_response_declined_sent", contactName)
+            }
+            ConversationNoticeItem(text, r)
+        } else {
+            val text = when {
+                r.wasAccepted() ->
+                    i18nF("groups_invitations_response_accepted_received", contactName)
+                else ->
+                    i18nF("groups_invitations_response_declined_received", contactName)
+            }
+            ConversationNoticeItem(text, r)
+        }
+    }
+
+    override fun visitIntroductionRequest(r: IntroductionRequest): ConversationItem {
+        // todo: use displayName logic somehow?
+        val name = r.nameable.name
+        return if (r.isLocal)
+            ConversationNoticeItem(
+                i18nF("introduction_request_sent", contactName, name),
+                r
+            )
+        else {
+            val text = when {
+                r.wasAnswered() ->
+                    i18nF("introduction_request_answered_received", contactName, name)
+                r.isContact ->
+                    i18nF("introduction_request_exists_received", contactName, name)
+                else ->
+                    i18nF("introduction_request_received", contactName, name)
+            }
+            ConversationRequestItem(
+                text,
+                ConversationRequestItem.RequestType.INTRODUCTION, r
+            )
+        }
+    }
+
+    override fun visitIntroductionResponse(r: IntroductionResponse): ConversationItem {
+        // todo: use displayName logic somehow?
+        val name = r.introducedAuthor.name
+        return if (r.isLocal) {
+            val text = when {
+                r.wasAccepted() -> {
+                    val suffix = if (r.canSucceed())
+                        "\n\n" + i18nF("introduction_response_accepted_sent_info", name)
+                    else ""
+                    i18nF("introduction_request_answered_received", name) + suffix
+                }
+                r.isAutoDecline ->
+                    i18nF("introduction_response_declined_auto", name)
+                else ->
+                    i18nF("introduction_response_declined_sent", name)
+            }
+            ConversationNoticeItem(text, r)
+        } else {
+            val text = when {
+                r.wasAccepted() ->
+                    i18nF("introduction_response_accepted_received", contactName, name)
+                r.isIntroducer ->
+                    i18nF("introduction_response_declined_received", contactName, name)
+                else ->
+                    i18nF("introduction_response_declined_received_by_introducee", contactName, name)
+            }
+            ConversationNoticeItem(text, r)
+        }
+    }
+}