diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogInput.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogInput.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5b9743813f8d99de33d53b7631cf607944a8c89c
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogInput.kt
@@ -0,0 +1,138 @@
+/*
+ * 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.blog
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+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.Close
+import androidx.compose.material.icons.filled.Send
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment.Companion.Top
+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.util.StringUtils.utf8IsTooLong
+import org.briarproject.briar.api.blog.BlogConstants.MAX_BLOG_COMMENT_TEXT_LENGTH
+import org.briarproject.briar.api.blog.BlogConstants.MAX_BLOG_POST_TEXT_LENGTH
+import org.briarproject.briar.desktop.theme.divider
+import org.briarproject.briar.desktop.theme.sendButton
+import org.briarproject.briar.desktop.ui.HorizontalDivider
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+import org.briarproject.briar.desktop.utils.StringUtils.takeUtf8
+
+@Composable
+@OptIn(ExperimentalComposeUiApi::class)
+fun BlogInput(
+    selectedBlogPost: BlogPost?,
+    onReplyClosed: () -> Unit,
+    onSend: (String) -> Unit,
+) {
+    val messageText = rememberSaveable { mutableStateOf("") }
+    val maxLength = if (selectedBlogPost == null) MAX_BLOG_POST_TEXT_LENGTH else MAX_BLOG_COMMENT_TEXT_LENGTH
+    val onSendAction = {
+        val text = messageText.value
+        if ((text.isNotBlank() || selectedBlogPost != null) && !utf8IsTooLong(messageText.value, maxLength)) {
+            onSend(text)
+            messageText.value = ""
+        }
+    }
+    Column {
+        if (selectedBlogPost != null) {
+            Row(
+                verticalAlignment = Top,
+                modifier = Modifier.border(1.dp, MaterialTheme.colors.divider),
+            ) {
+                Column(
+                    modifier = Modifier
+                        .padding(start = 8.dp, top = 8.dp, bottom = 8.dp)
+                        .weight(1f),
+                ) {
+                    Text(
+                        text = i18n("blog.post.reply.intro"),
+                        modifier = Modifier.padding(bottom = 8.dp),
+                    )
+                    BlogPostView(
+                        item = selectedBlogPost,
+                        onItemRepeat = null,
+                    )
+                }
+                IconButton(
+                    icon = Icons.Filled.Close,
+                    contentDescription = i18n("access.blogs.reply.close"),
+                    onClick = onReplyClosed,
+                )
+            }
+        }
+        HorizontalDivider()
+        TextField(
+            value = messageText.value,
+            onValueChange = { messageText.value = it.takeUtf8(maxLength) },
+            onEnter = onSendAction,
+            maxLines = 10,
+            textStyle = MaterialTheme.typography.body1,
+            placeholder = {
+                Text(
+                    text = if (selectedBlogPost == null) {
+                        i18n("blog.post.hint")
+                    } else {
+                        i18n("blog.post.reply.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/blog/BlogPostItem.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogPostItem.kt
new file mode 100644
index 0000000000000000000000000000000000000000..33e02ae3ea77a191d07291396f726f2ab7abda56
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogPostItem.kt
@@ -0,0 +1,86 @@
+/*
+ * 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.blog
+
+import org.briarproject.bramble.api.identity.Author
+import org.briarproject.bramble.api.sync.GroupId
+import org.briarproject.bramble.api.sync.MessageId
+import org.briarproject.briar.api.blog.BlogCommentHeader
+import org.briarproject.briar.api.blog.BlogPostHeader
+import org.briarproject.briar.api.identity.AuthorInfo
+
+sealed class BlogPost(
+    open val header: BlogPostHeader,
+    open val text: String?,
+) : Comparable<BlogPostItem> {
+    abstract val postHeader: BlogPostHeader
+    val isRead: Boolean get() = header.isRead
+    val id: MessageId get() = header.id
+    val groupId: GroupId get() = header.groupId
+    val timestamp: Long get() = header.timestamp
+    val author: Author get() = header.author
+    val authorInfo: AuthorInfo get() = header.authorInfo
+    val isRssFeed: Boolean get() = header.isRssFeed
+
+    override operator fun compareTo(other: BlogPostItem): Int {
+        return if (this === other) 0 else other.header.timeReceived.compareTo(header.timeReceived)
+    }
+}
+
+data class BlogPostItem(
+    override val header: BlogPostHeader,
+    override val text: String,
+) : BlogPost(header, text) {
+    override val postHeader: BlogPostHeader get() = header
+}
+
+data class BlogCommentItem(
+    override val header: BlogCommentHeader,
+    override val postHeader: BlogPostHeader,
+    override val text: String?,
+) : BlogPost(header, text) {
+
+    companion object {
+        fun getBlogPostHeader(header: BlogPostHeader): BlogPostHeader {
+            return if (header is BlogCommentHeader) {
+                getBlogPostHeader(header.parent)
+            } else {
+                header
+            }
+        }
+    }
+
+    private val _comments = ArrayList<BlogCommentHeader>()
+    val comments: List<BlogCommentHeader> get() = _comments
+
+    init {
+        collectComments(header)
+        // TODO check order
+        _comments.sortBy { it.timestamp }
+    }
+
+    private fun collectComments(header: BlogPostHeader): BlogPostHeader {
+        return if (header is BlogCommentHeader) {
+            if (header.comment != null) _comments.add(header)
+            collectComments(header.parent)
+        } else {
+            header
+        }
+    }
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogPostView.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogPostView.kt
new file mode 100644
index 0000000000000000000000000000000000000000..10083b21a36fe0824ec6dc9166b7569fe8c9eaed
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogPostView.kt
@@ -0,0 +1,260 @@
+/*
+ * 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.blog
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement.spacedBy
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material.Card
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Repeat
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Alignment.Companion.BottomEnd
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import org.briarproject.bramble.api.sync.GroupId
+import org.briarproject.bramble.api.sync.MessageId
+import org.briarproject.briar.api.blog.BlogCommentHeader
+import org.briarproject.briar.api.blog.BlogPostHeader
+import org.briarproject.briar.api.blog.MessageType
+import org.briarproject.briar.api.identity.AuthorInfo
+import org.briarproject.briar.api.identity.AuthorInfo.Status.OURSELVES
+import org.briarproject.briar.desktop.contact.ProfileCircle
+import org.briarproject.briar.desktop.ui.AuthorView
+import org.briarproject.briar.desktop.ui.HorizontalDivider
+import org.briarproject.briar.desktop.ui.TrustIndicatorShort
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+import org.briarproject.briar.desktop.utils.PreviewUtils.preview
+import org.briarproject.briar.desktop.utils.TimeUtils
+import org.briarproject.briar.desktop.utils.UiUtils
+import org.briarproject.briar.desktop.utils.getRandomAuthor
+import org.briarproject.briar.desktop.utils.getRandomId
+import org.briarproject.briar.desktop.utils.getRandomString
+import kotlin.random.Random
+
+@Suppress("HardCodedStringLiteral")
+fun main() = preview {
+    Column(verticalArrangement = spacedBy(8.dp)) {
+        val post = getRandomBlogPostItem(
+            text = "This is a normal blog post.\n\nIt has one author and no comments.",
+            time = System.currentTimeMillis() - 999_000
+        )
+        BlogPostView(post, {})
+        val commentPost = getRandomBlogCommentItem(
+            post = post,
+            comment = "This is a comment on that first blog post.\n\nIt has two lines as well.",
+            time = System.currentTimeMillis() - 500_000
+        )
+        BlogPostView(commentPost, {})
+        BlogPostView(
+            getRandomBlogCommentItem(
+                post = commentPost,
+                comment = "This is a second comment on that first blog post. It has only one line, but a long one.",
+                time = System.currentTimeMillis()
+            ),
+            null,
+        )
+        BlogPostView(getRandomBlogPost(getRandomString(1337), 1337), null)
+    }
+}
+
+@Composable
+fun BlogPostView(
+    item: BlogPost,
+    onItemRepeat: ((BlogPost) -> Unit)?,
+    modifier: Modifier = Modifier,
+) = Card(modifier = modifier) {
+    Column {
+        BlogPostViewHeader(item, onItemRepeat, Modifier.padding(8.dp))
+        // should be changed back to verticalArrangement = spacedBy(8.dp) on the containing Column
+        // when https://github.com/JetBrains/compose-jb/issues/2729 is fixed
+        Spacer(Modifier.height(8.dp))
+        SelectionContainer {
+            Text(
+                modifier = Modifier.padding(horizontal = 8.dp).fillMaxWidth(),
+                text = item.text ?: "",
+            )
+        }
+        Spacer(Modifier.height(8.dp))
+        // if no preview and a comment item, show comments
+        if (onItemRepeat != null && item is BlogCommentItem) {
+            item.comments.forEach { commentItem ->
+                BlogCommentView(commentItem, modifier = Modifier.padding(vertical = 4.dp))
+            }
+        }
+    }
+}
+
+@Composable
+private fun BlogPostViewHeader(
+    item: BlogPost,
+    onItemRepeat: ((BlogPost) -> Unit)?,
+    modifier: Modifier = Modifier,
+) {
+    Row(
+        modifier = modifier,
+        horizontalArrangement = spacedBy(8.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        Column(
+            verticalArrangement = spacedBy(8.dp),
+            modifier = Modifier.weight(1f)
+        ) {
+            RepeatAuthorView(item)
+            if (item is BlogCommentItem) {
+                val postHeader = item.postHeader
+                AuthorView(postHeader.author, postHeader.authorInfo, postHeader.timestamp)
+            }
+        }
+        if (onItemRepeat != null) IconButton(onClick = { onItemRepeat(item) }) {
+            Icon(
+                imageVector = Icons.Default.Repeat,
+                contentDescription = i18n("access.blogs.reblog"),
+            )
+        }
+    }
+}
+
+@Composable
+private fun RepeatAuthorView(item: BlogPost, modifier: Modifier = Modifier) {
+    val author = item.author
+    val authorInfo = item.authorInfo
+    val timestamp = item.timestamp
+    Row(
+        horizontalArrangement = spacedBy(8.dp),
+        verticalAlignment = Alignment.CenterVertically,
+        modifier = modifier,
+    ) {
+        Row(
+            modifier = Modifier.weight(1f),
+            horizontalArrangement = spacedBy(8.dp),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            Box(
+                contentAlignment = BottomEnd,
+                modifier = Modifier.size(36.dp),
+            ) {
+                ProfileCircle(36.dp, author.id, authorInfo)
+                if (item is BlogCommentItem) {
+                    Icon(
+                        imageVector = Icons.Default.Repeat,
+                        tint = Color.Black,
+                        contentDescription = i18n("access.blogs.reblog"),
+                        modifier = Modifier.size(16.dp).clip(CircleShape)
+                            .border(1.dp, Color.Black, CircleShape).background(Color.White).padding(2.dp)
+                    )
+                }
+            }
+            Text(
+                modifier = Modifier.weight(1f, fill = false),
+                text = UiUtils.getContactDisplayName(author.name, authorInfo.alias),
+                fontWeight = if (authorInfo.status == OURSELVES) FontWeight.Bold else null,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis,
+            )
+            TrustIndicatorShort(authorInfo.status)
+        }
+        Text(
+            text = TimeUtils.getFormattedTimestamp(timestamp),
+            textAlign = TextAlign.End,
+            style = MaterialTheme.typography.caption,
+            maxLines = 1,
+        )
+    }
+}
+
+internal fun getRandomBlogPostItem(text: String, time: Long) = BlogPostItem(
+    header = BlogPostHeader(
+        MessageType.POST,
+        GroupId(getRandomId()),
+        MessageId(getRandomId()),
+        time,
+        System.currentTimeMillis(),
+        getRandomAuthor(),
+        AuthorInfo(AuthorInfo.Status.values().filter { it != AuthorInfo.Status.NONE }.random()),
+        Random.nextBoolean() && Random.nextBoolean() && Random.nextBoolean(),
+        Random.nextBoolean(),
+    ),
+    text = text,
+)
+
+@Composable
+private fun BlogCommentView(header: BlogCommentHeader, modifier: Modifier = Modifier) {
+    val comment = header.comment
+    if (comment != null) Column(
+        verticalArrangement = spacedBy(8.dp),
+        modifier = modifier,
+    ) {
+        HorizontalDivider()
+        AuthorView(header.author, header.authorInfo, header.timestamp, Modifier.padding(horizontal = 16.dp))
+        SelectionContainer {
+            Text(
+                modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
+                text = comment,
+            )
+        }
+    }
+}
+
+internal fun getRandomBlogCommentItem(post: BlogPost, comment: String?, time: Long) = BlogCommentItem(
+    header = BlogCommentHeader(
+        MessageType.COMMENT,
+        GroupId(getRandomId()),
+        comment,
+        if (post is BlogCommentItem) post.header else post.postHeader,
+        MessageId(getRandomId()),
+        time,
+        System.currentTimeMillis(),
+        getRandomAuthor(),
+        AuthorInfo(AuthorInfo.Status.values().filter { it != AuthorInfo.Status.NONE }.random()),
+        Random.nextBoolean(),
+    ),
+    postHeader = post.postHeader,
+    text = comment,
+)
+
+internal fun getRandomBlogPost(text: String, time: Long): BlogPost {
+    val postItem = getRandomBlogPostItem(text, time - 999_000)
+    return if (Random.nextBoolean()) {
+        postItem
+    } else {
+        val comment = if (Random.nextBoolean()) null else text
+        getRandomBlogCommentItem(postItem, comment, time)
+    }
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogScreen.kt
new file mode 100644
index 0000000000000000000000000000000000000000..657b084b141cbad079ad1f8b7c738ff3f60de074
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogScreen.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.blog
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.Scaffold
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment.Companion.Center
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+import org.briarproject.briar.desktop.viewmodel.viewModel
+
+@Composable
+fun BlogScreen(viewModel: FeedViewModel = viewModel()) {
+    Scaffold(
+        content = { padding ->
+            if (viewModel.isLoading.value) {
+                Box(
+                    contentAlignment = Center,
+                    modifier = Modifier.padding(padding).fillMaxSize()
+                ) {
+                    CircularProgressIndicator(Modifier.padding(16.dp))
+                }
+            } else {
+                if (viewModel.posts.isEmpty()) {
+                    Box(
+                        contentAlignment = Center,
+                        modifier = Modifier.padding(padding).fillMaxSize()
+                    ) {
+                        Text(i18n("blog.empty.state"), Modifier.padding(16.dp))
+                    }
+                } else {
+                    FeedScreen(viewModel.posts, viewModel::selectPost, Modifier.padding(padding))
+                }
+            }
+        },
+        bottomBar = {
+            val onCloseReply = { viewModel.selectPost(null) }
+            BlogInput(viewModel.selectedPost.value, onCloseReply) { text ->
+                viewModel.createBlogPost(text)
+            }
+        }
+    )
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/FeedScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/FeedScreen.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5aa585e8baa906c036273329abe9614162a6e4d2
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/FeedScreen.kt
@@ -0,0 +1,108 @@
+/*
+ * 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.blog
+
+import androidx.compose.foundation.VerticalScrollbar
+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.heightIn
+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.selectableGroup
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+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.ui.Constants
+import org.briarproject.briar.desktop.ui.isWindowFocused
+import org.briarproject.briar.desktop.utils.PreviewUtils.preview
+import java.time.Instant
+
+@Suppress("HardCodedStringLiteral")
+fun main() = preview(
+    "text" to "This is a test blog post!\n\nThis is a blog post! This is a blog post! This is a blog post!",
+    "timestamp" to Instant.now().toEpochMilli(),
+    "numPosts" to 42,
+) {
+    val items = buildList {
+        for (i in 1..getIntParameter("numPosts")) {
+            add(getRandomBlogPost(getStringParameter("text"), getLongParameter("timestamp")))
+        }
+    }
+    FeedScreen(items, {})
+}
+
+@Composable
+fun FeedScreen(posts: List<BlogPost>, onItemSelected: (BlogPost) -> Unit, modifier: Modifier = Modifier) {
+    val scrollState = rememberLazyListState()
+    // scroll to first unread item if needed
+    val lastUnreadIndex = posts.indexOfLast { item -> !item.isRead }
+    if (lastUnreadIndex > -1) LaunchedEffect(posts) {
+        scrollState.scrollToItem(lastUnreadIndex, -50)
+    }
+    Box(modifier = modifier.fillMaxSize()) {
+        LazyColumn(
+            state = scrollState,
+            modifier = Modifier.padding(end = 8.dp).selectableGroup()
+        ) {
+            items(
+                items = posts,
+                key = { item -> item.id },
+            ) { item ->
+                BlogPostView(
+                    item = item,
+                    onItemRepeat = onItemSelected,
+                    modifier = Modifier
+                        .heightIn(min = Constants.HEADER_SIZE)
+                        .fillMaxWidth()
+                        .padding(vertical = 8.dp)
+                        .padding(start = 16.dp, end = 8.dp)
+                )
+            }
+        }
+        VerticalScrollbar(
+            adapter = rememberScrollbarAdapter(scrollState),
+            modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight()
+        )
+        if (isWindowFocused()) {
+            // if Briar Desktop currently has focus,
+            // mark all posts visible on the screen as read after some delay
+            LaunchedEffect(
+                posts,
+                scrollState.firstVisibleItemIndex,
+                scrollState.firstVisibleItemScrollOffset
+            ) {
+                delay(2_500)
+                val visibleMessageIds = scrollState.layoutInfo.reallyVisibleItemsInfo.map {
+                    it.key as MessageId
+                }
+                // TODO onBlogPostsVisible(visibleMessageIds)
+            }
+        }
+    }
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/FeedViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/FeedViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..16a759336af5eaf368e8ff0ebb735e62208f5daa
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/FeedViewModel.kt
@@ -0,0 +1,179 @@
+/*
+ * 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.blog
+
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+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.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.GroupRemovedEvent
+import org.briarproject.briar.api.blog.BlogCommentHeader
+import org.briarproject.briar.api.blog.BlogManager
+import org.briarproject.briar.api.blog.BlogPostFactory
+import org.briarproject.briar.api.blog.BlogPostHeader
+import org.briarproject.briar.api.blog.event.BlogPostAddedEvent
+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.asList
+import org.briarproject.briar.desktop.viewmodel.asState
+import org.briarproject.briar.util.HtmlUtils
+import javax.inject.Inject
+
+class FeedViewModel @Inject constructor(
+    private val blogManager: BlogManager,
+    private val blogPostFactory: BlogPostFactory,
+    private val identityManager: IdentityManager,
+    briarExecutors: BriarExecutors,
+    lifecycleManager: LifecycleManager,
+    db: TransactionManager,
+    eventBus: EventBus,
+) : EventListenerDbViewModel(briarExecutors, lifecycleManager, db, eventBus) {
+
+    companion object {
+        private val LOG = KotlinLogging.logger {}
+    }
+
+    private val _isLoading = mutableStateOf(true)
+    val isLoading = _isLoading.asState()
+
+    private val _posts = mutableStateListOf<BlogPost>()
+    val posts = _posts.asList()
+
+    private val _selectedPost = mutableStateOf<BlogPost?>(null)
+    val selectedPost = _selectedPost.asState()
+
+    init {
+        runOnDbThreadWithTransaction(true, this::loadAllBlogPosts)
+    }
+
+    @Suppress("HardCodedStringLiteral")
+    override fun eventOccurred(e: Event) {
+        if (e is BlogPostAddedEvent) {
+            LOG.info("Blog post added")
+            onBlogPostAdded(e.header, e.isLocal)
+        } else if (e is GroupRemovedEvent && e.group.clientId == BlogManager.CLIENT_ID) {
+            LOG.info("Blog removed")
+            onBlogRemoved(e.group.id)
+        }
+    }
+
+    @DatabaseExecutor
+    private fun loadAllBlogPosts(txn: Transaction) {
+        val posts = blogManager.getBlogIds(txn).flatMap { g ->
+            loadBlogPosts(txn, g)
+        }.sortedByDescending { blogPost ->
+            blogPost.header.timeReceived
+        }
+        txn.attach {
+            _posts.addAll(posts)
+            _isLoading.value = false
+        }
+    }
+
+    @DatabaseExecutor
+    private fun loadBlogPosts(txn: Transaction, groupId: GroupId): List<BlogPost> {
+        return blogManager.getPostHeaders(txn, groupId).map { h ->
+            getItem(txn, h)
+        }
+    }
+
+    @UiExecutor
+    private fun onBlogPostAdded(header: BlogPostHeader, local: Boolean) {
+        runOnDbThreadWithTransaction(true) { txn ->
+            val item = getItem(txn, header)
+
+            LOG.error { "${item::class.java} ${item.text}" }
+
+            txn.attach {
+                _posts.add(item)
+                _posts.sortByDescending { blogPost ->
+                    blogPost.header.timeReceived
+                }
+                _isLoading.value = false
+            }
+        }
+    }
+
+    @UiExecutor
+    private fun onBlogRemoved(id: GroupId) {
+        _posts.removeIf { it.id == id }
+    }
+
+    @UiExecutor
+    fun selectPost(item: BlogPost?) {
+        _selectedPost.value = item
+        if (item != null && !item.isRead) markPostsRead(listOf(item.id))
+    }
+
+    @UiExecutor
+    fun createBlogPost(text: String) {
+        val parentPost = selectedPost.value
+        runOnDbThread {
+            val author = identityManager.localAuthor
+            val blog = blogManager.getPersonalBlog(author)
+            if (parentPost == null) {
+                val p = blogPostFactory.createBlogPost(
+                    blog.id,
+                    System.currentTimeMillis(),
+                    null,
+                    author,
+                    text,
+                )
+                blogManager.addLocalPost(p)
+            } else {
+                val comment = text.takeIf { it.isNotBlank() }
+                blogManager.addLocalComment(author, blog.id, comment, parentPost.header)
+            }
+        }
+        _selectedPost.value = null
+    }
+
+    @UiExecutor
+    fun markPostsRead(postIds: List<MessageId>) {
+        runOnDbThread {
+            postIds.forEach { id ->
+                blogManager.setReadFlag(id, true)
+            }
+        }
+    }
+
+    @DatabaseExecutor
+    private fun getItem(txn: Transaction, header: BlogPostHeader): BlogPost {
+        return if (header is BlogCommentHeader) {
+            val postHeader = BlogCommentItem.getBlogPostHeader(header)
+            BlogCommentItem(header, postHeader, getPostText(txn, postHeader.id))
+        } else {
+            BlogPostItem(header, getPostText(txn, header.id))
+        }
+    }
+
+    @DatabaseExecutor
+    private fun getPostText(txn: Transaction, m: MessageId): String {
+        return HtmlUtils.cleanArticle(blogManager.getPostText(txn, m))
+    }
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/AuthorView.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/AuthorView.kt
index 0e213b2bc2f8d316448648e409bd35d384cee4b1..c395f0d79f1b6cc7fb1f2bac970b2877f25920cc 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/AuthorView.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/AuthorView.kt
@@ -28,6 +28,7 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.text.font.FontWeight.Companion.Bold
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextOverflow.Companion.Ellipsis
+import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import org.briarproject.bramble.api.identity.Author
 import org.briarproject.briar.api.identity.AuthorInfo
@@ -37,17 +38,24 @@ import org.briarproject.briar.desktop.utils.TimeUtils
 import org.briarproject.briar.desktop.utils.UiUtils.getContactDisplayName
 
 @Composable
-fun AuthorView(author: Author, authorInfo: AuthorInfo, timestamp: Long, modifier: Modifier = Modifier) {
+fun AuthorView(
+    author: Author,
+    authorInfo: AuthorInfo,
+    timestamp: Long,
+    modifier: Modifier = Modifier,
+    avatarSize: Dp = 27.dp,
+) {
     Row(
         modifier = modifier,
         horizontalArrangement = spacedBy(8.dp),
+        verticalAlignment = CenterVertically,
     ) {
         Row(
             modifier = Modifier.weight(1f),
             horizontalArrangement = spacedBy(8.dp),
             verticalAlignment = CenterVertically,
         ) {
-            ProfileCircle(27.dp, author.id, authorInfo)
+            ProfileCircle(avatarSize, author.id, authorInfo)
             Text(
                 modifier = Modifier.weight(1f, fill = false),
                 text = getContactDisplayName(author.name, authorInfo.alias),
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt
index b1b37c56c4631f7de11b4caab5546c5193a2f7db..549f6b401c4403278b79e2ec668b8589aeffe3f2 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt
@@ -27,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.staticCompositionLocalOf
+import org.briarproject.briar.desktop.blog.BlogScreen
 import org.briarproject.briar.desktop.conversation.PrivateMessageScreen
 import org.briarproject.briar.desktop.forum.ForumScreen
 import org.briarproject.briar.desktop.mailbox.MailboxScreen
@@ -64,6 +65,7 @@ fun MainScreen(viewModel: SidebarViewModel = viewModel()) {
                     UiMode.CONTACTS -> PrivateMessageScreen()
                     UiMode.GROUPS -> PrivateGroupScreen()
                     UiMode.FORUMS -> ForumScreen()
+                    UiMode.BLOGS -> BlogScreen()
                     UiMode.MAILBOX -> MailboxScreen()
                     UiMode.SETTINGS -> SettingsScreen()
                     UiMode.ABOUT -> AboutScreen()
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt
index aee751faa6747bd25f232a1aa21ca6422da90383..c8cb981890f07b28479bd1412dbacc11ca79caf5 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt
@@ -22,6 +22,7 @@ import dagger.Binds
 import dagger.MapKey
 import dagger.Module
 import dagger.multibindings.IntoMap
+import org.briarproject.briar.desktop.blog.FeedViewModel
 import org.briarproject.briar.desktop.contact.ContactListViewModel
 import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel
 import org.briarproject.briar.desktop.conversation.ConversationViewModel
@@ -91,6 +92,11 @@ abstract class ViewModelModule {
     @ViewModelKey(PrivateGroupSharingViewModel::class)
     abstract fun bindPrivateGroupSharingViewModel(privateGroupSharingViewModel: PrivateGroupSharingViewModel): ViewModel
 
+    @Binds
+    @IntoMap
+    @ViewModelKey(FeedViewModel::class)
+    abstract fun bindFeedViewModel(feedViewModel: FeedViewModel): ViewModel
+
     @Binds
     @IntoMap
     @ViewModelKey(MailboxViewModel::class)
diff --git a/briar-desktop/src/main/resources/strings/BriarDesktop.properties b/briar-desktop/src/main/resources/strings/BriarDesktop.properties
index fc63b461da0f3fb5d74e16869e575e5692d2a4f4..3ca62d2bb7c139ed7bb44bae3409b2833fe22408 100644
--- a/briar-desktop/src/main/resources/strings/BriarDesktop.properties
+++ b/briar-desktop/src/main/resources/strings/BriarDesktop.properties
@@ -99,6 +99,10 @@ access.group.last_post_timestamp=last message: {0}
 access.group.jump_to_prev_unread=Jump to previous unread message
 access.group.jump_to_next_unread=Jump to next unread message
 
+access.blogs.list=blog feed
+access.blogs.reply.close=Close comment
+access.blogs.reblog=Reblog
+
 # Contacts
 contacts.none_selected.title=No contact selected
 contacts.none_selected.hint=Select a contact to start chatting
@@ -212,6 +216,12 @@ group.invite.action.title=Invite Contacts
 group.invite.action.no_contacts=No contacts yet. You can only invite contacts to your private group.
 group.mark.read=Mark all as read
 
+# Blogs
+blog.empty.state=No posts to show
+blog.post.reply.intro=Reblog this post:
+blog.post.hint=Type your blog post
+blog.post.reply.hint=Add a comment (optional)
+
 # Introduction
 introduction.introduce=Make Introduction
 introduction.message=Add a message (optional)