diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactIdWrapper.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactIdWrapper.kt
deleted file mode 100644
index 1521ebe45a9b68913e1d78a9e28f23bba17924a7..0000000000000000000000000000000000000000
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactIdWrapper.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- * 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.contact
-
-sealed interface ContactIdWrapper
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactItem.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactItem.kt
index 0321f6d0616f61e691f3cf7718b0bef6f36b795c..3888c516a8d1d22f94ca8b5b5d4d61737ec6b988 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactItem.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactItem.kt
@@ -19,15 +19,22 @@
 package org.briarproject.briar.desktop.contact
 
 import androidx.compose.ui.graphics.ImageBitmap
+import org.briarproject.bramble.api.connection.ConnectionRegistry
 import org.briarproject.bramble.api.contact.Contact
+import org.briarproject.bramble.api.contact.ContactId
+import org.briarproject.bramble.api.db.Transaction
 import org.briarproject.bramble.api.identity.AuthorId
+import org.briarproject.briar.api.attachment.AttachmentReader
 import org.briarproject.briar.api.client.MessageTracker
+import org.briarproject.briar.api.conversation.ConversationManager
 import org.briarproject.briar.api.identity.AuthorInfo
+import org.briarproject.briar.api.identity.AuthorManager
+import org.briarproject.briar.desktop.utils.ImageUtils
 import org.briarproject.briar.desktop.utils.UiUtils.getContactDisplayName
 import kotlin.math.max
 
 data class ContactItem(
-    override val idWrapper: RealContactIdWrapper,
+    val id: ContactId,
     val authorId: AuthorId,
     val trustLevel: AuthorInfo.Status,
     private val name: String,
@@ -37,8 +44,11 @@ data class ContactItem(
     val unread: Int,
     override val timestamp: Long,
     val avatar: ImageBitmap?,
-) : BaseContactItem {
+) : ContactListItem {
 
+    data class Id(val id: ContactId) : ContactListItemId
+
+    override val wrapperId = Id(id)
     override val displayName = getContactDisplayName(name, alias)
 
     constructor(
@@ -48,7 +58,7 @@ data class ContactItem(
         groupCount: MessageTracker.GroupCount,
         avatar: ImageBitmap?
     ) : this(
-        idWrapper = RealContactIdWrapper(contact.id),
+        id = contact.id,
         authorId = contact.author.id,
         trustLevel = authorInfo.status,
         name = contact.author.name,
@@ -79,3 +89,21 @@ data class ContactItem(
     fun updateAvatar(avatar: ImageBitmap?) =
         copy(avatar = avatar)
 }
+
+fun loadContactItem(
+    txn: Transaction,
+    contact: Contact,
+    authorManager: AuthorManager,
+    connectionRegistry: ConnectionRegistry,
+    conversationManager: ConversationManager,
+    attachmentReader: AttachmentReader
+): ContactItem {
+    val authorInfo = authorManager.getAuthorInfo(txn, contact)
+    return ContactItem(
+        contact,
+        authorInfo,
+        connectionRegistry.isConnected(contact.id),
+        conversationManager.getGroupCount(txn, contact.id),
+        authorInfo.avatarHeader?.let { ImageUtils.loadImage(txn, attachmentReader, it) },
+    )
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactCard.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactItemView.kt
similarity index 56%
rename from briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactCard.kt
rename to briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactItemView.kt
index 717f5e1efa21b441812328e238ebb65656842217..bae6112798841f110379930a4083af5186c9f649 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactCard.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactItemView.kt
@@ -18,7 +18,6 @@
 
 package org.briarproject.briar.desktop.contact
 
-import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Arrangement.spacedBy
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
@@ -28,32 +27,22 @@ import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.requiredSize
-import androidx.compose.foundation.selection.selectable
 import androidx.compose.foundation.selection.selectableGroup
-import androidx.compose.material.IconButton
 import androidx.compose.material.MaterialTheme
 import androidx.compose.material.ProvideTextStyle
 import androidx.compose.material.Text
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Delete
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.semantics.Role
-import androidx.compose.ui.semantics.contentDescription
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.semantics.text
 import androidx.compose.ui.text.style.TextOverflow.Companion.Ellipsis
 import androidx.compose.ui.unit.dp
 import org.briarproject.bramble.api.contact.ContactId
-import org.briarproject.bramble.api.contact.PendingContactId
-import org.briarproject.bramble.api.contact.PendingContactState
 import org.briarproject.bramble.api.identity.AuthorId
 import org.briarproject.briar.api.identity.AuthorInfo.Status
-import org.briarproject.briar.desktop.theme.selectedCard
 import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE
-import org.briarproject.briar.desktop.ui.HorizontalDivider
+import org.briarproject.briar.desktop.ui.ListItemView
 import org.briarproject.briar.desktop.ui.NumberBadge
 import org.briarproject.briar.desktop.ui.TrustIndicatorLong
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
@@ -78,9 +67,9 @@ fun main() = preview(
     "selected" to false,
 ) {
     Column(Modifier.selectableGroup()) {
-        ContactCard(
-            ContactItem(
-                idWrapper = RealContactIdWrapper(ContactId(0)),
+        ListItemView(getBooleanParameter("selected")) {
+            val item = ContactItem(
+                id = ContactId(0),
                 authorId = AuthorId(getRandomIdPersistent()),
                 trustLevel = Status.valueOf(getStringParameter("trustLevel")),
                 name = getStringParameter("name"),
@@ -90,68 +79,24 @@ fun main() = preview(
                 unread = getIntParameter("unread"),
                 timestamp = getLongParameter("timestamp"),
                 avatar = null,
-            ),
-            {}, getBooleanParameter("selected"), {}
-        )
-        ContactCard(
-            PendingContactItem(
-                idWrapper = PendingContactIdWrapper(PendingContactId(getRandomId())),
-                alias = getStringParameter("alias"),
-                timestamp = getLongParameter("timestamp"),
-                state = PendingContactState.ADDING_CONTACT
-            ),
-            {}, false, {}
-        )
-    }
-}
-
-@Composable
-fun ContactCard(
-    contactItem: BaseContactItem,
-    onSel: () -> Unit,
-    selected: Boolean,
-    onRemovePending: () -> Unit,
-) {
-    val bgColor = if (selected) MaterialTheme.colors.selectedCard else Color.Transparent
-
-    Box(
-        contentAlignment = Alignment.Center,
-        modifier = Modifier
-            .fillMaxWidth()
-            .defaultMinSize(minHeight = HEADER_SIZE)
-            .semantics {
-                contentDescription = if (selected) i18n("access.list.selected.yes")
-                else i18n("access.list.selected.no")
-                // todo: stateDescription apparently not used
-                // stateDescription = if (selected) "selected" else "not selected"
-            }
-            .selectable(selected, onClick = onSel, role = Role.Button)
-            .background(bgColor),
-    ) {
-        when (contactItem) {
-            is ContactItem -> {
-                RealContactRow(contactItem)
-            }
-
-            is PendingContactItem -> {
-                PendingContactRow(contactItem, onRemovePending)
-            }
+            )
+            ContactItemView(item)
         }
-        HorizontalDivider(Modifier.align(Alignment.BottomStart))
     }
 }
 
 @Composable
-private fun RealContactRow(contactItem: ContactItem) = Row(
+fun ContactItemView(contactItem: ContactItem) = Row(
     horizontalArrangement = spacedBy(8.dp),
     verticalAlignment = Alignment.CenterVertically,
     modifier = Modifier
+        .defaultMinSize(minHeight = HEADER_SIZE)
         .fillMaxWidth()
         .padding(vertical = 8.dp)
         // makes sure that ConnectionIndicator is aligned with AddContact button
         .padding(start = 16.dp, end = 20.dp)
         .semantics {
-            text = getRealContactRowDescription(contactItem)
+            text = getDescription(contactItem)
         }
 ) {
     Row(
@@ -166,7 +111,7 @@ private fun RealContactRow(contactItem: ContactItem) = Row(
                 modifier = Modifier.align(Alignment.TopEnd).offset(6.dp, (-6).dp)
             )
         }
-        RealContactInfo(
+        ContactItemViewInfo(
             contactItem = contactItem,
         )
     }
@@ -176,8 +121,9 @@ private fun RealContactRow(contactItem: ContactItem) = Row(
     )
 }
 
-fun getRealContactRowDescription(contactItem: ContactItem) = buildBlankAnnotatedString {
+private fun getDescription(contactItem: ContactItem) = buildBlankAnnotatedString {
     append(i18nF("access.contact.with_name", contactItem.displayName))
+    // todo: trust level!
     appendCommaSeparated(
         if (contactItem.isConnected) i18n("access.contact.connected.yes")
         else i18n("access.contact.connected.no")
@@ -197,48 +143,7 @@ fun getRealContactRowDescription(contactItem: ContactItem) = buildBlankAnnotated
 }
 
 @Composable
-private fun PendingContactRow(contactItem: PendingContactItem, onRemove: () -> Unit) = Row(
-    horizontalArrangement = spacedBy(8.dp),
-    verticalAlignment = Alignment.CenterVertically,
-    modifier = Modifier
-        .fillMaxWidth()
-        .padding(vertical = 8.dp)
-        // makes sure that Delete button is aligned with AddContact button
-        .padding(start = 16.dp, end = 4.dp)
-        .semantics {
-            text = getPendingContactRowDescription(contactItem)
-        },
-) {
-    Row(
-        verticalAlignment = Alignment.CenterVertically,
-        horizontalArrangement = spacedBy(12.dp),
-        modifier = Modifier.weight(1f, fill = true),
-    ) {
-        ProfileCircle(36.dp)
-        PendingContactInfo(
-            contactItem = contactItem,
-        )
-    }
-    IconButton(
-        icon = Icons.Filled.Delete,
-        contentDescription = i18n("access.contacts.pending.remove"),
-        onClick = onRemove,
-    )
-}
-
-fun getPendingContactRowDescription(contactItem: PendingContactItem) = buildBlankAnnotatedString {
-    append(i18nF("access.contact.pending.with_name", contactItem.displayName))
-    // todo: include pending status
-    appendCommaSeparated(
-        i18nF(
-            "access.contact.pending.added_timestamp",
-            getFormattedTimestamp(contactItem.timestamp)
-        )
-    )
-}
-
-@Composable
-private fun RealContactInfo(contactItem: ContactItem) = Column(
+private fun ContactItemViewInfo(contactItem: ContactItem) = Column(
     horizontalAlignment = Alignment.Start,
     verticalArrangement = spacedBy(2.dp),
 ) {
@@ -257,20 +162,3 @@ private fun RealContactInfo(contactItem: ContactItem) = Column(
         style = MaterialTheme.typography.caption,
     )
 }
-
-@Composable
-private fun PendingContactInfo(contactItem: PendingContactItem) = Column(
-    horizontalAlignment = Alignment.Start,
-    verticalArrangement = spacedBy(2.dp),
-) {
-    Text(
-        text = contactItem.displayName,
-        style = MaterialTheme.typography.body1,
-        maxLines = 3,
-        overflow = Ellipsis,
-    )
-    Text(
-        text = getFormattedTimestamp(contactItem.timestamp),
-        style = MaterialTheme.typography.caption,
-    )
-}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactItemViewSmall.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactItemViewSmall.kt
new file mode 100644
index 0000000000000000000000000000000000000000..fda601dc0d796f32c5d2c00c219bd6b394382434
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactItemViewSmall.kt
@@ -0,0 +1,131 @@
+/*
+ * 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.contact
+
+import androidx.compose.foundation.layout.Arrangement.spacedBy
+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.requiredSize
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.text
+import androidx.compose.ui.text.style.TextOverflow.Companion.Ellipsis
+import androidx.compose.ui.unit.dp
+import org.briarproject.bramble.api.contact.ContactId
+import org.briarproject.bramble.api.identity.AuthorId
+import org.briarproject.briar.api.identity.AuthorInfo.Status
+import org.briarproject.briar.desktop.ui.ListItemView
+import org.briarproject.briar.desktop.ui.TrustIndicatorShort
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18nF
+import org.briarproject.briar.desktop.utils.PreviewUtils.DropDownValues
+import org.briarproject.briar.desktop.utils.PreviewUtils.preview
+import org.briarproject.briar.desktop.utils.appendCommaSeparated
+import org.briarproject.briar.desktop.utils.buildBlankAnnotatedString
+import java.time.Instant
+
+@Suppress("HardCodedStringLiteral")
+fun main() = preview(
+    "name" to "Paul",
+    "alias" to "UI Master",
+    "trustLevel" to DropDownValues(0, Status.values().filterNot { it == Status.NONE }.map { it.name }),
+    "isConnected" to true,
+    "isEmpty" to false,
+    "unread" to 3,
+    "timestamp" to Instant.now().toEpochMilli(),
+    "selected" to false,
+) {
+    Column(Modifier.selectableGroup()) {
+        ListItemView(getBooleanParameter("selected")) {
+            ContactItemViewSmall(
+                ContactItem(
+                    id = ContactId(0),
+                    authorId = AuthorId(getRandomIdPersistent()),
+                    trustLevel = Status.valueOf(getStringParameter("trustLevel")),
+                    name = getStringParameter("name"),
+                    alias = getStringParameter("alias"),
+                    isConnected = getBooleanParameter("isConnected"),
+                    isEmpty = getBooleanParameter("isEmpty"),
+                    unread = getIntParameter("unread"),
+                    timestamp = getLongParameter("timestamp"),
+                    avatar = null,
+                ),
+            )
+        }
+    }
+}
+
+@Composable
+fun ContactItemViewSmall(
+    contactItem: ContactItem,
+    showConnectionState: Boolean = true,
+) = Row(
+    horizontalArrangement = spacedBy(8.dp),
+    verticalAlignment = Alignment.CenterVertically,
+    modifier = Modifier
+        .fillMaxWidth()
+        .padding(vertical = 8.dp)
+        // makes sure that ConnectionIndicator is aligned with AddContact button
+        .padding(start = 16.dp, end = 20.dp)
+        .semantics {
+            text = getDescription(contactItem, showConnectionState)
+        }
+) {
+    Row(
+        verticalAlignment = Alignment.CenterVertically,
+        horizontalArrangement = spacedBy(8.dp),
+        modifier = Modifier.weight(1f, fill = true),
+    ) {
+        // TODO cache profile images, if available
+        ProfileCircle(20.dp, contactItem)
+        Text(
+            text = contactItem.displayName,
+            style = MaterialTheme.typography.body1,
+            maxLines = 3,
+            overflow = Ellipsis,
+        )
+        TrustIndicatorShort(contactItem.trustLevel)
+    }
+    if (showConnectionState)
+        ConnectionIndicator(
+            modifier = Modifier.requiredSize(12.dp),
+            isConnected = contactItem.isConnected
+        )
+}
+
+private fun getDescription(
+    contactItem: ContactItem,
+    showConnectionState: Boolean,
+) = buildBlankAnnotatedString {
+    append(i18nF("access.contact.with_name", contactItem.displayName))
+    // todo: trust level!
+    if (showConnectionState)
+        appendCommaSeparated(
+            if (contactItem.isConnected) i18n("access.contact.connected.yes")
+            else i18n("access.contact.connected.no")
+        )
+    append('.')
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactList.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactList.kt
index c2b33acd9b8ce8e2a57f545cc017611b01b7ffb4..61ecfb57f6234659881a1d8cfce989035ec14dfb 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactList.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactList.kt
@@ -36,22 +36,91 @@ import androidx.compose.material.Surface
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.PersonAdd
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.semantics.contentDescription
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.unit.dp
+import org.briarproject.bramble.api.contact.ContactId
+import org.briarproject.bramble.api.contact.PendingContactId
+import org.briarproject.bramble.api.contact.PendingContactState
+import org.briarproject.bramble.api.identity.AuthorId
+import org.briarproject.briar.api.identity.AuthorInfo
+import org.briarproject.briar.desktop.contact.add.remote.PendingContactItem
+import org.briarproject.briar.desktop.contact.add.remote.PendingContactItemView
 import org.briarproject.briar.desktop.theme.surfaceVariant
 import org.briarproject.briar.desktop.ui.Constants.COLUMN_WIDTH
 import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE
+import org.briarproject.briar.desktop.ui.ListItemView
 import org.briarproject.briar.desktop.ui.SearchTextField
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+import org.briarproject.briar.desktop.utils.PreviewUtils.preview
+import java.time.Instant
+
+@Suppress("HardCodedStringLiteral")
+fun main() = preview {
+    val list = remember {
+        listOf(
+            ContactItem(
+                id = ContactId(0),
+                authorId = AuthorId(getRandomId()),
+                trustLevel = AuthorInfo.Status.VERIFIED,
+                name = "Maria",
+                alias = "Mary",
+                isConnected = true,
+                isEmpty = false,
+                unread = 2,
+                timestamp = Instant.now().toEpochMilli(),
+                avatar = null,
+            ),
+            PendingContactItem(
+                id = PendingContactId(getRandomId()),
+                alias = "Thomas",
+                timestamp = (Instant.now().minusSeconds(300)).toEpochMilli(),
+                state = PendingContactState.ADDING_CONTACT,
+            ),
+            ContactItem(
+                id = ContactId(1),
+                authorId = AuthorId(getRandomId()),
+                trustLevel = AuthorInfo.Status.UNVERIFIED,
+                name = "Anna",
+                alias = null,
+                isConnected = false,
+                isEmpty = true,
+                unread = 0,
+                timestamp = (Instant.now().minusSeconds(300)).toEpochMilli(),
+                avatar = null,
+            ),
+        )
+    }
+
+    val (selected, setSelected) = remember { mutableStateOf<ContactListItem?>(null) }
+    val (filterBy, setFilterBy) = remember { mutableStateOf("") }
+
+    val filteredList = remember(filterBy) {
+        list.filter {
+            it.displayName.contains(filterBy, ignoreCase = true)
+        }.sortedByDescending { it.timestamp }
+    }
+
+    ContactList(
+        contactList = filteredList,
+        isSelected = { selected == it },
+        selectContact = setSelected,
+        removePendingContact = {},
+        filterBy = filterBy,
+        setFilterBy = setFilterBy,
+        onContactAdd = {},
+    )
+}
 
 @Composable
 fun ContactList(
-    contactList: List<BaseContactItem>,
-    isSelected: (BaseContactItem) -> Boolean,
-    selectContact: (BaseContactItem) -> Unit,
+    contactList: List<ContactListItem>,
+    isSelected: (ContactListItem) -> Boolean,
+    selectContact: (ContactListItem) -> Unit,
     removePendingContact: (PendingContactItem) -> Unit,
     filterBy: String,
     setFilterBy: (String) -> Unit,
@@ -86,17 +155,30 @@ fun ContactList(
                         }
                         .selectableGroup()
                 ) {
-                    items(contactList) { contactItem ->
-                        ContactCard(
-                            contactItem,
-                            onSel = { selectContact(contactItem) },
-                            selected = isSelected(contactItem),
-                            onRemovePending = {
-                                if (contactItem is PendingContactItem) {
-                                    removePendingContact(contactItem)
+                    items(
+                        items = contactList,
+                        key = { item -> item.wrapperId },
+                        contentType = { item -> item::class }
+                    ) { item ->
+                        ListItemView(
+                            onSelect = { selectContact(item) },
+                            selected = isSelected(item),
+                        ) {
+                            when (item) {
+                                is ContactItem -> {
+                                    ContactItemView(item)
                                 }
-                            },
-                        )
+
+                                is PendingContactItem -> {
+                                    PendingContactItemView(
+                                        contactItem = item,
+                                        onRemove = {
+                                            removePendingContact(item)
+                                        },
+                                    )
+                                }
+                            }
+                        }
                     }
                 }
 
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/BaseContactItem.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListItem.kt
similarity index 90%
rename from briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/BaseContactItem.kt
rename to briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListItem.kt
index edc3c7cef678d062bfd2271ceed5cae3ca4bdf41..76a3a924115239f4f99224bb868a7f57bf28af10 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/BaseContactItem.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListItem.kt
@@ -18,9 +18,10 @@
 
 package org.briarproject.briar.desktop.contact
 
-sealed interface BaseContactItem {
-
-    val idWrapper: ContactIdWrapper
+interface ContactListItem {
     val displayName: String
     val timestamp: Long
+    val wrapperId: ContactListItemId
 }
+
+interface ContactListItemId
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt
index e242cbccfbdbd9c7dc088f2d364fca1769c04239..78a1934d843c807825e2acd350bee9a7e787f445 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactListViewModel.kt
@@ -19,26 +19,29 @@
 package org.briarproject.briar.desktop.contact
 
 import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.mutableStateListOf
 import androidx.compose.runtime.mutableStateOf
 import mu.KotlinLogging
 import org.briarproject.bramble.api.connection.ConnectionRegistry
 import org.briarproject.bramble.api.contact.ContactManager
 import org.briarproject.bramble.api.contact.PendingContactId
 import org.briarproject.bramble.api.contact.PendingContactState
-import org.briarproject.bramble.api.contact.event.ContactAliasChangedEvent
+import org.briarproject.bramble.api.contact.event.PendingContactAddedEvent
+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.lifecycle.LifecycleManager
 import org.briarproject.briar.api.attachment.AttachmentReader
-import org.briarproject.briar.api.avatar.event.AvatarUpdatedEvent
 import org.briarproject.briar.api.conversation.ConversationManager
 import org.briarproject.briar.api.conversation.event.ConversationMessageTrackedEvent
 import org.briarproject.briar.api.identity.AuthorManager
+import org.briarproject.briar.desktop.contact.add.remote.PendingContactItem
 import org.briarproject.briar.desktop.conversation.ConversationMessagesReadEvent
 import org.briarproject.briar.desktop.threading.BriarExecutors
-import org.briarproject.briar.desktop.utils.ImageUtils
 import org.briarproject.briar.desktop.utils.KLoggerUtils.i
+import org.briarproject.briar.desktop.utils.clearAndAddAll
+import org.briarproject.briar.desktop.utils.removeFirst
 import org.briarproject.briar.desktop.viewmodel.asState
 import javax.inject.Inject
 
@@ -49,7 +52,7 @@ constructor(
     authorManager: AuthorManager,
     conversationManager: ConversationManager,
     connectionRegistry: ConnectionRegistry,
-    private val attachmentReader: AttachmentReader,
+    attachmentReader: AttachmentReader,
     briarExecutors: BriarExecutors,
     lifecycleManager: LifecycleManager,
     db: TransactionManager,
@@ -75,16 +78,30 @@ constructor(
         loadContacts()
     }
 
+    private val _pendingContactList = mutableStateListOf<PendingContactItem>()
+
     private val _filterBy = mutableStateOf("")
-    private val _selectedContactId = mutableStateOf<ContactIdWrapper?>(null)
+    private val _selectedContactId = mutableStateOf<ContactListItemId?>(null)
     private val _contactIdToBeRemoved = mutableStateOf<PendingContactId?>(null)
 
+    // todo: check impact on performance due to reconstructing whole list on every change
+    val combinedContactList = derivedStateOf {
+        (_contactList + _pendingContactList)
+            .filter {
+                it.displayName.contains(_filterBy.value, ignoreCase = true)
+            }.sortedByDescending { it.timestamp }
+    }
+
+    val noContactsYet = derivedStateOf {
+        _contactList.isEmpty() && _pendingContactList.isEmpty()
+    }
+
     val filterBy = _filterBy.asState()
     val selectedContactId = derivedStateOf {
         // reset selected contact to null if not part of list after filtering
-        val id = _selectedContactId.value
-        if (id == null || contactList.value.map { it.idWrapper }.contains(id)) {
-            id
+        val wrapperId = _selectedContactId.value
+        if (wrapperId == null || combinedContactList.value.find { it.wrapperId == wrapperId } != null) {
+            wrapperId
         } else {
             _selectedContactId.value = null
             null
@@ -92,19 +109,32 @@ constructor(
     }
     val removePendingContactDialogVisible = derivedStateOf { _contactIdToBeRemoved.value != null }
 
-    fun selectContact(contactItem: BaseContactItem) {
-        _selectedContactId.value = contactItem.idWrapper
+    override fun loadContactsWithinTransaction(txn: Transaction) {
+        // load real contacts
+        super.loadContactsWithinTransaction(txn)
+
+        // load pending contacts
+        val pendingContactList = contactManager.getPendingContacts(txn).map { contact ->
+            PendingContactItem(contact.first, contact.second)
+        }
+        txn.attach {
+            _pendingContactList.clearAndAddAll(pendingContactList)
+        }
+    }
+
+    fun selectContact(contactItem: ContactListItem) {
+        _selectedContactId.value = contactItem.wrapperId
     }
 
-    fun isSelected(contactItem: BaseContactItem) = _selectedContactId.value == contactItem.idWrapper
+    fun isSelected(contactItem: ContactListItem) = _selectedContactId.value == contactItem.wrapperId
 
     fun removePendingContact(contactItem: PendingContactItem) {
         if (contactItem.state == PendingContactState.FAILED) {
             // no need to show warning dialog for failed pending contacts
-            removePendingContact(contactItem.idWrapper.contactId)
+            removePendingContact(contactItem.id)
         } else {
             // show warning dialog
-            _contactIdToBeRemoved.value = contactItem.idWrapper.contactId
+            _contactIdToBeRemoved.value = contactItem.id
         }
     }
 
@@ -123,14 +153,11 @@ constructor(
             contactManager.removePendingContact(txn, contactId)
             _contactIdToBeRemoved.value = null
             txn.attach {
-                removeItem(contactId)
+                removePendingContactItem(contactId)
             }
         }
     }
 
-    override fun filterContactItem(contactItem: BaseContactItem) =
-        contactItem.displayName.contains(_filterBy.value, ignoreCase = true)
-
     fun setFilterBy(filter: String) {
         _filterBy.value = filter
     }
@@ -140,28 +167,24 @@ constructor(
         when (e) {
             is ConversationMessageTrackedEvent -> {
                 LOG.i { "Conversation message tracked, updating item" }
-                updateItem(e.contactId) { it.updateTimestampAndUnread(e.timestamp, e.read) }
-            }
-            is ContactAliasChangedEvent -> {
-                updateItem(e.contactId) { it.updateAlias(e.alias) }
+                updateContactItem(e.contactId) { it.updateTimestampAndUnread(e.timestamp, e.read) }
             }
+
             is ConversationMessagesReadEvent -> {
                 LOG.i { "${e.count} conversation messages read, updating item" }
-                updateItem(e.contactId) { it.updateFromMessagesRead(e.count) }
+                updateContactItem(e.contactId) { it.updateFromMessagesRead(e.count) }
             }
-            is AvatarUpdatedEvent -> {
-                LOG.i { "received avatar update: ${e.attachmentHeader}" }
-                if (e.attachmentHeader == null) {
-                    updateItem(e.contactId) { it.updateAvatar(null) }
-                } else {
-                    runOnDbThreadWithTransaction(true) { txn ->
-                        val image = ImageUtils.loadImage(txn, attachmentReader, e.attachmentHeader)
-                        txn.attach {
-                            updateItem(e.contactId) { it.updateAvatar(image) }
-                        }
-                    }
-                }
+
+            is PendingContactAddedEvent -> {
+                LOG.i { "Pending contact added, reloading" }
+                loadContacts()
             }
+
+            // todo: is PendingContactRemovedEvent
+            // todo: is PendingContactStateChangedEvent
         }
     }
+
+    private fun removePendingContactItem(contactId: PendingContactId) =
+        _pendingContactList.removeFirst { it.id == contactId }
 }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactsViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactsViewModel.kt
index f8af78fb4c3988bbbc446fbc572275e31b2eb242..0cc29e6278f96beb12ea23ad1c3bd0b4d4c05882 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactsViewModel.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ContactsViewModel.kt
@@ -18,16 +18,16 @@
 
 package org.briarproject.briar.desktop.contact
 
-import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.mutableStateListOf
 import mu.KotlinLogging
 import org.briarproject.bramble.api.connection.ConnectionRegistry
+import org.briarproject.bramble.api.contact.Contact
 import org.briarproject.bramble.api.contact.ContactId
 import org.briarproject.bramble.api.contact.ContactManager
-import org.briarproject.bramble.api.contact.PendingContactId
 import org.briarproject.bramble.api.contact.event.ContactAddedEvent
+import org.briarproject.bramble.api.contact.event.ContactAliasChangedEvent
 import org.briarproject.bramble.api.contact.event.ContactRemovedEvent
-import org.briarproject.bramble.api.contact.event.PendingContactAddedEvent
+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
@@ -35,9 +35,11 @@ import org.briarproject.bramble.api.lifecycle.LifecycleManager
 import org.briarproject.bramble.api.plugin.event.ContactConnectedEvent
 import org.briarproject.bramble.api.plugin.event.ContactDisconnectedEvent
 import org.briarproject.briar.api.attachment.AttachmentReader
+import org.briarproject.briar.api.avatar.event.AvatarUpdatedEvent
 import org.briarproject.briar.api.conversation.ConversationManager
 import org.briarproject.briar.api.identity.AuthorManager
 import org.briarproject.briar.desktop.threading.BriarExecutors
+import org.briarproject.briar.desktop.threading.UiExecutor
 import org.briarproject.briar.desktop.utils.ImageUtils
 import org.briarproject.briar.desktop.utils.KLoggerUtils.i
 import org.briarproject.briar.desktop.utils.clearAndAddAll
@@ -61,87 +63,66 @@ abstract class ContactsViewModel(
         private val LOG = KotlinLogging.logger {}
     }
 
-    private val _fullContactList = mutableStateListOf<BaseContactItem>()
+    protected val _contactList = mutableStateListOf<ContactItem>()
 
-    val noContactsYet = derivedStateOf {
-        _fullContactList.isEmpty()
-    }
-
-    val contactList = derivedStateOf {
-        _fullContactList.filter(::filterContactItem).sortedByDescending { it.timestamp }
-    }
+    @UiExecutor
+    fun loadContacts() = runOnDbThreadWithTransaction(true, ::loadContactsWithinTransaction)
 
-    protected open fun filterContactItem(contactItem: BaseContactItem) = true
+    open fun loadContactsWithinTransaction(txn: Transaction) {
+        val contactList = contactManager.getContacts(txn).map { contact ->
+            loadContactItemWithinTransaction(txn, contact)
+        }
 
-    open fun loadContacts() {
-        val contactList = mutableListOf<BaseContactItem>()
-        runOnDbThreadWithTransaction(true) { txn ->
-            contactList.addAll(
-                contactManager.getPendingContacts(txn).map { contact ->
-                    PendingContactItem(contact.first, contact.second)
-                }
-            )
-            contactList.addAll(
-                contactManager.getContacts(txn).map { contact ->
-                    val authorInfo = authorManager.getAuthorInfo(txn, contact)
-                    ContactItem(
-                        contact,
-                        authorInfo,
-                        connectionRegistry.isConnected(contact.id),
-                        conversationManager.getGroupCount(txn, contact.id),
-                        authorInfo.avatarHeader?.let { ImageUtils.loadImage(txn, attachmentReader, it) },
-                    )
-                }
-            )
-            txn.attach {
-                _fullContactList.clearAndAddAll(contactList)
-            }
+        txn.attach {
+            _contactList.clearAndAddAll(contactList)
         }
     }
 
+    open fun loadContactItemWithinTransaction(txn: Transaction, contact: Contact) =
+        loadContactItem(txn, contact, authorManager, connectionRegistry, conversationManager, attachmentReader)
+
     override fun eventOccurred(e: Event?) {
         when (e) {
             is ContactAddedEvent -> {
+                // todo: instead, add single new item!
                 LOG.i { "Contact added, reloading" }
                 loadContacts()
             }
-            is PendingContactAddedEvent -> {
-                LOG.i { "Pending contact added, reloading" }
-                loadContacts()
-            }
+
             is ContactConnectedEvent -> {
                 LOG.i { "Contact connected, update state" }
-                updateItem(e.contactId) {
-                    it.updateIsConnected(true)
-                }
+                updateContactItem(e.contactId) { it.updateIsConnected(true) }
             }
+
             is ContactDisconnectedEvent -> {
                 LOG.i { "Contact disconnected, update state" }
-                updateItem(e.contactId) { it.updateIsConnected(false) }
+                updateContactItem(e.contactId) { it.updateIsConnected(false) }
             }
+
             is ContactRemovedEvent -> {
                 LOG.i { "Contact removed, removing item" }
-                removeItem(e.contactId)
+                removeContactItem(e.contactId)
             }
-        }
-    }
 
-    protected open fun updateItem(contactId: ContactId, update: (ContactItem) -> ContactItem) {
-        _fullContactList.replaceFirst(
-            { it.idWrapper.contactId == contactId },
-            update
-        )
-    }
+            is ContactAliasChangedEvent -> {
+                updateContactItem(e.contactId) { it.updateAlias(e.alias) }
+            }
 
-    protected open fun removeItem(contactId: ContactId) {
-        _fullContactList.removeFirst<BaseContactItem, ContactItem> {
-            it.idWrapper.contactId == contactId
+            is AvatarUpdatedEvent -> {
+                LOG.i { "received avatar update: ${e.attachmentHeader}" }
+                runOnDbThreadWithTransaction(true) { txn ->
+                    val image = ImageUtils.loadImage(txn, attachmentReader, e.attachmentHeader)
+                    txn.attach {
+                        updateContactItem(e.contactId) { it.updateAvatar(image) }
+                    }
+                }
+            }
         }
     }
 
-    protected open fun removeItem(contactId: PendingContactId) {
-        _fullContactList.removeFirst<BaseContactItem, PendingContactItem> {
-            it.idWrapper.contactId == contactId
-        }
-    }
+    protected fun updateContactItem(contactId: ContactId, update: (ContactItem) -> ContactItem) =
+        _contactList.replaceFirst({ it.id == contactId }, update)
+
+    protected fun removeContactItem(contactId: ContactId) =
+        _contactList.removeFirst { it.id == contactId }
 }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/PendingContactIdWrapper.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/PendingContactIdWrapper.kt
deleted file mode 100644
index be8746e621db7ee025ac76106e9b25aadbc5cc98..0000000000000000000000000000000000000000
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/PendingContactIdWrapper.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * 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.contact
-
-import org.briarproject.bramble.api.contact.PendingContactId
-
-data class PendingContactIdWrapper(val contactId: PendingContactId) : ContactIdWrapper
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/RealContactIdWrapper.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/RealContactIdWrapper.kt
deleted file mode 100644
index bb2a07c10c2ab3ba3c53f36a9b52013ac7ccb722..0000000000000000000000000000000000000000
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/RealContactIdWrapper.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * 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.contact
-
-import org.briarproject.bramble.api.contact.ContactId
-
-data class RealContactIdWrapper(val contactId: ContactId) : ContactIdWrapper
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/PendingContactItem.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/PendingContactItem.kt
similarity index 74%
rename from briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/PendingContactItem.kt
rename to briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/PendingContactItem.kt
index 59e5f4a3eb693e34efbf87fe2d76f928d8f1c07e..5f7965c6eed87410477c01a060e178b11c30b742 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/PendingContactItem.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/PendingContactItem.kt
@@ -16,22 +16,27 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-package org.briarproject.briar.desktop.contact
+package org.briarproject.briar.desktop.contact.add.remote
 
 import org.briarproject.bramble.api.contact.PendingContact
+import org.briarproject.bramble.api.contact.PendingContactId
 import org.briarproject.bramble.api.contact.PendingContactState
+import org.briarproject.briar.desktop.contact.ContactListItem
+import org.briarproject.briar.desktop.contact.ContactListItemId
 
 data class PendingContactItem(
-    override val idWrapper: PendingContactIdWrapper,
+    val id: PendingContactId,
     val alias: String,
     override val timestamp: Long,
     val state: PendingContactState,
-) : BaseContactItem {
+) : ContactListItem {
+    data class Id(val id: PendingContactId) : ContactListItemId
 
+    override val wrapperId = Id(id)
     override val displayName = alias
 
     constructor(contact: PendingContact, state: PendingContactState) : this(
-        idWrapper = PendingContactIdWrapper(contact.id),
+        id = contact.id,
         alias = contact.alias,
         timestamp = contact.timestamp,
         state = state,
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/PendingContactItemView.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/PendingContactItemView.kt
new file mode 100644
index 0000000000000000000000000000000000000000..357c887b0003be05c82101b11d5835a10f3a9381
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/add/remote/PendingContactItemView.kt
@@ -0,0 +1,136 @@
+/*
+ * 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.contact.add.remote
+
+import androidx.compose.foundation.layout.Arrangement.spacedBy
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.selection.selectableGroup
+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.Delete
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.text
+import androidx.compose.ui.text.style.TextOverflow.Companion.Ellipsis
+import androidx.compose.ui.unit.dp
+import org.briarproject.bramble.api.contact.PendingContactId
+import org.briarproject.bramble.api.contact.PendingContactState
+import org.briarproject.briar.api.identity.AuthorInfo.Status
+import org.briarproject.briar.desktop.contact.ProfileCircle
+import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE
+import org.briarproject.briar.desktop.ui.ListItemView
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18nF
+import org.briarproject.briar.desktop.utils.PreviewUtils.DropDownValues
+import org.briarproject.briar.desktop.utils.PreviewUtils.preview
+import org.briarproject.briar.desktop.utils.TimeUtils.getFormattedTimestamp
+import org.briarproject.briar.desktop.utils.appendCommaSeparated
+import org.briarproject.briar.desktop.utils.buildBlankAnnotatedString
+import java.time.Instant
+
+@Suppress("HardCodedStringLiteral")
+fun main() = preview(
+    "name" to "Paul",
+    "alias" to "UI Master",
+    "trustLevel" to DropDownValues(0, Status.values().filterNot { it == Status.NONE }.map { it.name }),
+    "isConnected" to true,
+    "isEmpty" to false,
+    "unread" to 3,
+    "timestamp" to Instant.now().toEpochMilli(),
+    "selected" to false,
+) {
+    Column(Modifier.selectableGroup()) {
+        ListItemView(getBooleanParameter("selected")) {
+            val item = PendingContactItem(
+                id = PendingContactId(getRandomId()),
+                alias = getStringParameter("alias"),
+                timestamp = getLongParameter("timestamp"),
+                state = PendingContactState.ADDING_CONTACT
+            )
+            PendingContactItemView(item, onRemove = {})
+        }
+    }
+}
+
+@Composable
+fun PendingContactItemView(contactItem: PendingContactItem, onRemove: () -> Unit) = Row(
+    horizontalArrangement = spacedBy(8.dp),
+    verticalAlignment = Alignment.CenterVertically,
+    modifier = Modifier
+        .defaultMinSize(minHeight = HEADER_SIZE)
+        .fillMaxWidth()
+        .padding(vertical = 8.dp)
+        // makes sure that Delete button is aligned with AddContact button
+        .padding(start = 16.dp, end = 4.dp)
+        .semantics {
+            text = getDescription(contactItem)
+        },
+) {
+    Row(
+        verticalAlignment = Alignment.CenterVertically,
+        horizontalArrangement = spacedBy(12.dp),
+        modifier = Modifier.weight(1f, fill = true),
+    ) {
+        ProfileCircle(36.dp)
+        PendingContactItemViewInfo(
+            contactItem = contactItem,
+        )
+    }
+    IconButton(
+        icon = Icons.Filled.Delete,
+        contentDescription = i18n("access.contacts.pending.remove"),
+        onClick = onRemove,
+    )
+}
+
+private fun getDescription(contactItem: PendingContactItem) = buildBlankAnnotatedString {
+    append(i18nF("access.contact.pending.with_name", contactItem.alias))
+    // todo: include pending status
+    appendCommaSeparated(
+        i18nF(
+            "access.contact.pending.added_timestamp",
+            getFormattedTimestamp(contactItem.timestamp)
+        )
+    )
+}
+
+@Composable
+private fun PendingContactItemViewInfo(contactItem: PendingContactItem) = Column(
+    horizontalAlignment = Alignment.Start,
+    verticalArrangement = spacedBy(2.dp),
+) {
+    Text(
+        text = contactItem.alias,
+        style = MaterialTheme.typography.body1,
+        maxLines = 3,
+        overflow = Ellipsis,
+    )
+    Text(
+        text = getFormattedTimestamp(contactItem.timestamp),
+        style = MaterialTheme.typography.caption,
+    )
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt
index 6d11586d81a22a182ebf231ed6c62a4030a482cd..fee8ff4c99c06eb981323d3186b6211130c555d4 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/conversation/ConversationViewModel.kt
@@ -60,10 +60,10 @@ import org.briarproject.briar.api.messaging.PrivateMessageHeader
 import org.briarproject.briar.desktop.DesktopFeatureFlags
 import org.briarproject.briar.desktop.attachment.media.ImageCompressor
 import org.briarproject.briar.desktop.contact.ContactItem
+import org.briarproject.briar.desktop.contact.loadContactItem
 import org.briarproject.briar.desktop.conversation.ConversationRequestItem.RequestType.FORUM
 import org.briarproject.briar.desktop.conversation.ConversationRequestItem.RequestType.INTRODUCTION
 import org.briarproject.briar.desktop.threading.BriarExecutors
-import org.briarproject.briar.desktop.utils.ImageUtils.loadImage
 import org.briarproject.briar.desktop.utils.KLoggerUtils.e
 import org.briarproject.briar.desktop.utils.KLoggerUtils.i
 import org.briarproject.briar.desktop.utils.KLoggerUtils.logDuration
@@ -285,14 +285,8 @@ constructor(
             val start = LogUtils.now()
 
             val contact = contactManager.getContact(txn, id)
-            val authorInfo = authorManager.getAuthorInfo(txn, contact)
-            val contactItem = ContactItem(
-                contact,
-                authorInfo,
-                connectionRegistry.isConnected(id),
-                conversationManager.getGroupCount(txn, id),
-                authorInfo.avatarHeader?.let { loadImage(txn, attachmentReader, it) },
-            )
+            val contactItem =
+                loadContactItem(txn, contact, authorManager, connectionRegistry, conversationManager, attachmentReader)
             LOG.logDuration("Loading contact", start)
             txn.attach {
                 _contactItem.value = contactItem
@@ -314,7 +308,7 @@ constructor(
         _loadingMessages.value = true
         try {
             var start = LogUtils.now()
-            val headers = conversationManager.getMessageHeaders(txn, contact.idWrapper.contactId)
+            val headers = conversationManager.getMessageHeaders(txn, contact.id)
             LOG.logDuration("Loading message headers", start)
             // Sort headers by timestamp in *ascending* order
             val sorted = headers.sortedBy { it.timestamp }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageScreen.kt
index 42065c51fbebd05694032f75d8096e7f85b9de06..5f4b2bd7984d9307fdfd702be7129c4ffdc736fc 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageScreen.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/conversation/PrivateMessageScreen.kt
@@ -37,12 +37,12 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.unit.dp
 import org.briarproject.briar.desktop.contact.ConfirmRemovePendingContactDialog
+import org.briarproject.briar.desktop.contact.ContactItem
 import org.briarproject.briar.desktop.contact.ContactList
 import org.briarproject.briar.desktop.contact.ContactListViewModel
-import org.briarproject.briar.desktop.contact.PendingContactIdWrapper
-import org.briarproject.briar.desktop.contact.RealContactIdWrapper
 import org.briarproject.briar.desktop.contact.add.remote.AddContactDialog
 import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel
+import org.briarproject.briar.desktop.contact.add.remote.PendingContactItem
 import org.briarproject.briar.desktop.ui.BriarLogo
 import org.briarproject.briar.desktop.ui.ColoredIconButton
 import org.briarproject.briar.desktop.ui.Constants.PARAGRAPH_WIDTH
@@ -70,7 +70,7 @@ fun PrivateMessageScreen(
 
     Row(modifier = Modifier.fillMaxWidth()) {
         ContactList(
-            viewModel.contactList.value,
+            viewModel.combinedContactList.value,
             viewModel::isSelected,
             viewModel::selectContact,
             viewModel::removePendingContact,
@@ -80,14 +80,14 @@ fun PrivateMessageScreen(
         )
         VerticalDivider()
         Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
-            val id = viewModel.selectedContactId.value
-            if (id == null) {
+            val wrapperId = viewModel.selectedContactId.value
+            if (wrapperId == null) {
                 NoContactSelected()
-            } else when (id) {
-                is RealContactIdWrapper -> {
-                    ConversationScreen(id.contactId)
+            } else when (wrapperId) {
+                is ContactItem.Id -> {
+                    ConversationScreen(wrapperId.id)
                 }
-                is PendingContactIdWrapper -> {
+                is PendingContactItem.Id -> {
                     PendingContactSelected()
                 }
             }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumSharingDrawerContent.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumSharingDrawerContent.kt
index 14ea3742af0a748ea43145049802c15b80d8614d..3bfa7535a28e419000afaa8cf083c5f682b65b78 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumSharingDrawerContent.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumSharingDrawerContent.kt
@@ -42,11 +42,11 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.unit.dp
 import org.briarproject.bramble.api.sync.GroupId
-import org.briarproject.briar.desktop.contact.ContactCard
+import org.briarproject.briar.desktop.contact.ContactItemViewSmall
 import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE
 import org.briarproject.briar.desktop.ui.HorizontalDivider
+import org.briarproject.briar.desktop.ui.ListItemView
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
-import org.briarproject.briar.desktop.viewmodel.viewModel
 
 @Composable
 fun ForumSharingDrawerContent(
@@ -97,12 +97,9 @@ fun ForumSharingDrawerContent(
         } else {
             LazyColumn {
                 items(viewModel.currentlySharedWith) { contactItem ->
-                    ContactCard(
-                        contactItem,
-                        onSel = {},
-                        selected = false,
-                        onRemovePending = {},
-                    )
+                    ListItemView {
+                        ContactItemViewSmall(contactItem)
+                    }
                 }
             }
         }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumSharingViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumSharingViewModel.kt
index 3ddff4f582030405bb029102e83e41c2b893f428..7e6ac6a5a97ffb10db2119fa5817f4eda47e4362 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumSharingViewModel.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumSharingViewModel.kt
@@ -103,20 +103,20 @@ class ForumSharingViewModel @Inject constructor(
                 }
 
             e is ContactLeftShareableEvent && e.groupId == groupId -> {
-                _currentlySharedWith.removeFirst { it.idWrapper.contactId == e.contactId }
+                _currentlySharedWith.removeFirst { it.id == e.contactId }
                 val connected = connectionRegistry.isConnected(e.contactId)
                 _sharingInfo.update { removeContact(connected) }
             }
 
             e is ContactConnectedEvent -> {
-                val isMember = _currentlySharedWith.replaceFirst({ it.idWrapper.contactId == e.contactId }) {
+                val isMember = _currentlySharedWith.replaceFirst({ it.id == e.contactId }) {
                     it.updateIsConnected(true)
                 }
                 if (isMember) _sharingInfo.update { updateContactConnected(true) }
             }
 
             e is ContactDisconnectedEvent -> {
-                val isMember = _currentlySharedWith.replaceFirst({ it.idWrapper.contactId == e.contactId }) {
+                val isMember = _currentlySharedWith.replaceFirst({ it.id == e.contactId }) {
                     it.updateIsConnected(false)
                 }
                 if (isMember) _sharingInfo.update { updateContactConnected(false) }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/introduction/ContactDrawerMakeIntro.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/introduction/ContactDrawerMakeIntro.kt
index de17bd810dce5f79345f5b1ee9c5795eb82fe79b..fc835a87e711ec6f04281cd51a61e7991139fa9b 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/introduction/ContactDrawerMakeIntro.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/introduction/ContactDrawerMakeIntro.kt
@@ -44,12 +44,13 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.unit.dp
-import org.briarproject.briar.desktop.contact.ContactCard
 import org.briarproject.briar.desktop.contact.ContactItem
+import org.briarproject.briar.desktop.contact.ContactItemView
 import org.briarproject.briar.desktop.contact.ProfileCircle
 import org.briarproject.briar.desktop.theme.surfaceVariant
 import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE
 import org.briarproject.briar.desktop.ui.HorizontalDivider
+import org.briarproject.briar.desktop.ui.ListItemView
 import org.briarproject.briar.desktop.utils.InternationalizationUtils
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18nF
@@ -83,13 +84,12 @@ fun ContactDrawerMakeIntro(
                 HorizontalDivider()
                 LazyColumn {
                     items(viewModel.contactList.value) { contactItem ->
-                        if (contactItem is ContactItem)
-                            ContactCard(
-                                contactItem,
-                                onSel = { viewModel.setSecondContact(contactItem) },
-                                selected = false,
-                                onRemovePending = {},
-                            )
+                        ListItemView(
+                            selected = false,
+                            onSelect = { viewModel.setSecondContact(contactItem) },
+                        ) {
+                            ContactItemView(contactItem)
+                        }
                     }
                 }
             } else {
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/introduction/IntroductionViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/introduction/IntroductionViewModel.kt
index 961b3b3dd4378502c185b16628977d0d92ba1ee2..361ec9f67357c94a74cfa1e7e38f4e2ef378a1b6 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/introduction/IntroductionViewModel.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/introduction/IntroductionViewModel.kt
@@ -18,6 +18,7 @@
 
 package org.briarproject.briar.desktop.introduction
 
+import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.mutableStateOf
 import org.briarproject.bramble.api.connection.ConnectionRegistry
 import org.briarproject.bramble.api.contact.ContactManager
@@ -28,7 +29,6 @@ import org.briarproject.briar.api.attachment.AttachmentReader
 import org.briarproject.briar.api.conversation.ConversationManager
 import org.briarproject.briar.api.identity.AuthorManager
 import org.briarproject.briar.api.introduction.IntroductionManager
-import org.briarproject.briar.desktop.contact.BaseContactItem
 import org.briarproject.briar.desktop.contact.ContactItem
 import org.briarproject.briar.desktop.contact.ContactsViewModel
 import org.briarproject.briar.desktop.threading.BriarExecutors
@@ -70,6 +70,12 @@ constructor(
     val secondScreen = _secondScreen.asState()
     val introductionMessage = _introductionMessage.asState()
 
+    val contactList = derivedStateOf {
+        _contactList.filter {
+            it.id != _firstContact.value?.id
+        }.sortedByDescending { it.displayName }
+    }
+
     fun setFirstContact(contactItem: ContactItem) {
         _firstContact.value = contactItem
         loadContacts()
@@ -90,12 +96,6 @@ constructor(
         _introductionMessage.value = msg
     }
 
-    override fun filterContactItem(contactItem: BaseContactItem): Boolean {
-        return if (contactItem is ContactItem) {
-            _firstContact.value?.idWrapper != contactItem.idWrapper
-        } else false
-    }
-
     fun makeIntroduction() {
         val c1 = requireNotNull(_firstContact.value)
         val c2 = requireNotNull(_secondContact.value)
@@ -104,8 +104,8 @@ constructor(
         val msg = _introductionMessage.value.ifEmpty { null }
 
         runOnDbThread {
-            val c1 = contactManager.getContact(c1.idWrapper.contactId)
-            val c2 = contactManager.getContact(c2.idWrapper.contactId)
+            val c1 = contactManager.getContact(c1.id)
+            val c2 = contactManager.getContact(c2.id)
             introductionManager.makeIntroduction(c1, c2, msg)
         }
     }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/ListItemView.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/ListItemView.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b8dc6cd8bc03bc0752caf048ed432174b2c7acff
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/ListItemView.kt
@@ -0,0 +1,66 @@
+/*
+ * 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.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.material.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import org.briarproject.briar.desktop.theme.selectedCard
+import org.briarproject.briar.desktop.utils.InternationalizationUtils
+
+@Composable
+fun ListItemView(
+    selected: Boolean? = null,
+    onSelect: () -> Unit = {},
+    modifier: Modifier = Modifier,
+    content: @Composable () -> Unit,
+) {
+    val bgColor = if (selected != null && selected) MaterialTheme.colors.selectedCard else Color.Transparent
+
+    Box(
+        contentAlignment = Alignment.Center,
+        modifier = modifier
+            .fillMaxWidth()
+            .background(bgColor)
+            .then(
+                if (selected != null) {
+                    Modifier
+                        .semantics {
+                            contentDescription = if (selected) InternationalizationUtils.i18n("access.list.selected.yes")
+                            else InternationalizationUtils.i18n("access.list.selected.no")
+                            // todo: stateDescription apparently not used
+                            // stateDescription = if (selected) "selected" else "not selected"
+                        }
+                        .selectable(selected, onClick = onSelect, role = Role.Button)
+                } else Modifier
+            )
+    ) {
+        content()
+        HorizontalDivider(Modifier.align(Alignment.BottomStart))
+    }
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/TrustIndicator.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/TrustIndicator.kt
index 73bb089b58bcd3bb90c45fbe20c8121e9f2101ce..f47480991a2bf2d788d3f467c78df38b6f912795 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/TrustIndicator.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/TrustIndicator.kt
@@ -96,6 +96,7 @@ private fun TrustIndicatorContent(status: Status) {
                 withStyle(SpanStyle(color = second)) { append("#") }
                 withStyle(SpanStyle(color = third)) { append("#") }
             },
+            maxLines = 1,
             fontFamily = FontFamily.Monospace,
             fontWeight = FontWeight.Bold,
         )