From 0f286032f6ebe59f6ddb14b56c14aecb76034c97 Mon Sep 17 00:00:00 2001
From: Torsten Grote <t@grobox.de>
Date: Tue, 20 Sep 2022 18:38:41 -0300
Subject: [PATCH] Address review feedback for initial forum prototype

---
 .../briar/desktop/contact/ContactList.kt      |  8 ++++-
 .../briar/desktop/forums/AddForumDialog.kt    |  7 ++---
 .../forums/{ForumsItem.kt => ForumItem.kt}    | 31 ++++++++++++++++++-
 .../{ForumsScreen.kt => ForumScreen.kt}       | 10 +++---
 .../{ForumsViewModel.kt => ForumViewModel.kt} | 23 +++++++-------
 .../forums/{GroupsCard.kt => GroupCard.kt}    | 23 +++++++++-----
 .../briar/desktop/forums/GroupCircle.kt       | 13 ++++----
 .../desktop/forums/GroupConversationScreen.kt | 10 +++---
 .../desktop/forums/GroupInputComposable.kt    |  2 +-
 .../{ForumsList.kt => GroupListComposable.kt} | 14 ++++++---
 .../briar/desktop/forums/ThreadItem.kt        | 23 +++-----------
 .../briar/desktop/threading/BriarExecutors.kt |  5 +++
 .../desktop/threading/BriarExecutorsImpl.kt   | 15 +++++++++
 .../briar/desktop/ui/MainScreen.kt            |  4 +--
 .../{contact => ui}/SearchTextField.kt        |  6 ++--
 .../briar/desktop/viewmodel/DbViewModel.kt    | 25 +++++----------
 .../desktop/viewmodel/ViewModelModule.kt      |  6 ++--
 .../resources/strings/BriarDesktop.properties |  5 ++-
 18 files changed, 140 insertions(+), 90 deletions(-)
 rename briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/{ForumsItem.kt => ForumItem.kt} (55%)
 rename briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/{ForumsScreen.kt => ForumScreen.kt} (94%)
 rename briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/{ForumsViewModel.kt => ForumViewModel.kt} (91%)
 rename briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/{GroupsCard.kt => GroupCard.kt} (86%)
 rename briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/{ForumsList.kt => GroupListComposable.kt} (88%)
 rename briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/{contact => ui}/SearchTextField.kt (93%)

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 be304ce31c..c2b33acd9b 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
@@ -44,6 +44,7 @@ import androidx.compose.ui.unit.dp
 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.SearchTextField
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
 
 @Composable
@@ -70,6 +71,7 @@ fun ContactList(
                     placeholder = i18n("contacts.search.title"),
                     icon = Icons.Filled.PersonAdd,
                     searchValue = filterBy,
+                    addButtonDescription = i18n("access.contacts.add"),
                     onValueChange = setFilterBy,
                     onAddButtonClicked = onContactAdd,
                 )
@@ -89,7 +91,11 @@ fun ContactList(
                             contactItem,
                             onSel = { selectContact(contactItem) },
                             selected = isSelected(contactItem),
-                            onRemovePending = { if (contactItem is PendingContactItem) removePendingContact(contactItem) },
+                            onRemovePending = {
+                                if (contactItem is PendingContactItem) {
+                                    removePendingContact(contactItem)
+                                }
+                            },
                         )
                     }
                 }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/AddForumDialog.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/AddForumDialog.kt
index 26511da0f9..c0f6e74bc5 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/AddForumDialog.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/AddForumDialog.kt
@@ -52,10 +52,10 @@ import androidx.compose.ui.window.rememberDialogState
 import org.briarproject.briar.api.forum.ForumConstants.MAX_FORUM_NAME_LENGTH
 import org.briarproject.briar.desktop.utils.AccessibilityUtils.description
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
-import org.briarproject.briar.desktop.utils.PreviewUtils
+import org.briarproject.briar.desktop.utils.PreviewUtils.preview
 import java.awt.Dimension
 
-fun main() = PreviewUtils.preview {
+fun main() = preview {
     val visible = mutableStateOf(true)
     AddForumDialog(visible, {}, { visible.value = false })
 }
@@ -66,13 +66,13 @@ fun AddForumDialog(
     onCreate: (String) -> Unit,
     onCancelButtonClicked: () -> Unit,
 ) {
+    if (!visible.value) return
     Dialog(
         title = i18n("forum.add.title"),
         onCloseRequest = onCancelButtonClicked,
         state = rememberDialogState(
             position = WindowPosition(Alignment.Center),
         ),
-        visible = visible.value,
     ) {
         window.minimumSize = Dimension(360, 180)
         val scaffoldState = rememberScaffoldState()
@@ -101,7 +101,6 @@ fun AddForumDialog(
                         okButtonEnabled = isValidForumName(name.value),
                         onOkButtonClicked = {
                             onCreate(name.value)
-                            name.value = ""
                         },
                         onCancelButtonClicked = onCancelButtonClicked,
                     )
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsItem.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumItem.kt
similarity index 55%
rename from briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsItem.kt
rename to briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumItem.kt
index a8765ab1cd..b3b4820f94 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsItem.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumItem.kt
@@ -18,9 +18,16 @@
 
 package org.briarproject.briar.desktop.forums
 
+import androidx.compose.ui.text.AnnotatedString
 import org.briarproject.bramble.api.sync.GroupId
 import org.briarproject.briar.api.client.MessageTracker
 import org.briarproject.briar.api.forum.Forum
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18nF
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18nP
+import org.briarproject.briar.desktop.utils.TimeUtils.getFormattedTimestamp
+import org.briarproject.briar.desktop.utils.appendCommaSeparated
+import org.briarproject.briar.desktop.utils.buildBlankAnnotatedString
 
 interface GroupItem {
     val id: GroupId
@@ -28,9 +35,10 @@ interface GroupItem {
     val msgCount: Int
     val unread: Int
     val timestamp: Long
+    val description: AnnotatedString
 }
 
-data class ForumsItem(
+data class ForumItem(
     val forum: Forum,
     override val msgCount: Int,
     override val unread: Int,
@@ -46,4 +54,25 @@ data class ForumsItem(
 
     override val id: GroupId get() = forum.id
     override val name: String get() = forum.name
+
+    override val description: AnnotatedString
+        get() = buildBlankAnnotatedString {
+            append(name)
+            if (unread > 0) appendCommaSeparated(i18nP("access.forums.unread_count", unread))
+            if (msgCount == 0) appendCommaSeparated(i18n("group.card.no_posts"))
+            else appendCommaSeparated(
+                i18nF(
+                    "access.forums.last_message_timestamp",
+                    getFormattedTimestamp(timestamp)
+                )
+            )
+        }
+
+    override fun equals(other: Any?): Boolean {
+        return other is ForumItem && other.id == id
+    }
+
+    override fun hashCode(): Int {
+        return forum.hashCode()
+    }
 }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumScreen.kt
similarity index 94%
rename from briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsScreen.kt
rename to briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumScreen.kt
index 1b4cca977d..55b598364c 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsScreen.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumScreen.kt
@@ -36,8 +36,8 @@ import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
 import org.briarproject.briar.desktop.viewmodel.viewModel
 
 @Composable
-fun ForumsScreen(
-    viewModel: ForumsViewModel = viewModel(),
+fun ForumScreen(
+    viewModel: ForumViewModel = viewModel(),
 ) {
     val addDialogVisible = remember { mutableStateOf(false) }
     AddForumDialog(
@@ -49,12 +49,12 @@ fun ForumsScreen(
         onCancelButtonClicked = { addDialogVisible.value = false }
     )
 
-    if (viewModel.groupList.value.isEmpty()) {
+    if (viewModel.forumList.value.isEmpty()) {
         NoForumsYet { addDialogVisible.value = true }
     } else {
         Row(modifier = Modifier.fillMaxWidth()) {
-            ForumsList(
-                list = viewModel.groupList,
+            GroupListComposable(
+                list = viewModel.forumList,
                 isSelected = viewModel::isSelected,
                 filterBy = viewModel.filterBy,
                 onFilterSet = viewModel::setFilterBy,
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumViewModel.kt
similarity index 91%
rename from briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsViewModel.kt
rename to briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumViewModel.kt
index 8755aa850d..7b70e4b40c 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsViewModel.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumViewModel.kt
@@ -18,7 +18,6 @@
 
 package org.briarproject.briar.desktop.forums
 
-import androidx.compose.runtime.State
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.mutableStateListOf
 import androidx.compose.runtime.mutableStateOf
@@ -57,7 +56,7 @@ class Loaded(
     val posts: MutableList<ThreadItem> get() = messageTree.depthFirstOrder()
 }
 
-class ForumsViewModel @Inject constructor(
+class ForumViewModel @Inject constructor(
     private val forumManager: ForumManager,
     private val identityManager: IdentityManager,
     private val clock: Clock,
@@ -68,22 +67,22 @@ class ForumsViewModel @Inject constructor(
     eventBus: EventBus,
 ) : EventListenerDbViewModel(briarExecutors, lifecycleManager, db, eventBus) {
 
-    private val _fullGroupList = mutableStateListOf<ForumsItem>()
-    val groupList = derivedStateOf {
+    private val _fullForumList = mutableStateListOf<ForumItem>()
+    val forumList = derivedStateOf {
         val filter = _filterBy.value
-        _fullGroupList.filter { item ->
+        _fullForumList.filter { item ->
             item.name.contains(filter, ignoreCase = true)
         }.sortedByDescending { it.timestamp }
     }
 
     private val _selectedGroupItem = mutableStateOf<GroupItem?>(null)
-    val selectedGroupItem: State<GroupItem?> = _selectedGroupItem
+    val selectedGroupItem = _selectedGroupItem.asState()
 
     private val _filterBy = mutableStateOf("")
     val filterBy = _filterBy.asState()
 
     private val _posts = mutableStateOf<PostsState>(Loading)
-    val posts: State<PostsState> = _posts
+    val posts = _posts.asState()
 
     override fun onInit() {
         super.onInit()
@@ -108,13 +107,13 @@ class ForumsViewModel @Inject constructor(
     private fun loadGroups() {
         runOnDbThreadWithTransaction(true) { txn ->
             val list = forumManager.getForums(txn).map { forums ->
-                ForumsItem(
-                    forums,
-                    forumManager.getGroupCount(txn, forums.id),
+                ForumItem(
+                    forum = forums,
+                    groupCount = forumManager.getGroupCount(txn, forums.id),
                 )
             }
             txn.attach {
-                _fullGroupList.clearAndAddAll(list)
+                _fullForumList.clearAndAddAll(list)
             }
         }
     }
@@ -174,6 +173,6 @@ class ForumsViewModel @Inject constructor(
     }
 
     fun deleteGroup(groupItem: GroupItem) {
-        forumManager.removeForum((groupItem as ForumsItem).forum)
+        forumManager.removeForum((groupItem as ForumItem).forum)
     }
 }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupsCard.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupCard.kt
similarity index 86%
rename from briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupsCard.kt
rename to briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupCard.kt
index 68f3a255c0..7ef6bb884b 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupsCard.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupCard.kt
@@ -18,13 +18,13 @@
 
 package org.briarproject.briar.desktop.forums
 
-import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement.SpaceBetween
 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.selectable
 import androidx.compose.foundation.selection.selectableGroup
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.Card
@@ -35,8 +35,11 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.Alignment.Companion.CenterVertically
 import androidx.compose.ui.Alignment.Companion.Start
 import androidx.compose.ui.Modifier
+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.AnnotatedString
 import androidx.compose.ui.unit.dp
 import org.briarproject.bramble.api.sync.GroupId
 import org.briarproject.briar.desktop.theme.selectedCard
@@ -44,13 +47,14 @@ import org.briarproject.briar.desktop.theme.surfaceVariant
 import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18nP
-import org.briarproject.briar.desktop.utils.PreviewUtils
+import org.briarproject.briar.desktop.utils.PreviewUtils.preview
 import org.briarproject.briar.desktop.utils.TimeUtils.getFormattedTimestamp
+import org.briarproject.briar.desktop.utils.buildBlankAnnotatedString
 
 @Suppress("HardCodedStringLiteral")
-fun main() = PreviewUtils.preview {
+fun main() = preview {
     Column(Modifier.selectableGroup()) {
-        GroupsCard(
+        GroupCard(
             item = object : GroupItem {
                 override val id: GroupId = GroupId(getRandomId())
                 override val name: String =
@@ -58,6 +62,7 @@ fun main() = PreviewUtils.preview {
                 override val msgCount: Int = 42
                 override val unread: Int = 23
                 override val timestamp: Long = System.currentTimeMillis()
+                override val description: AnnotatedString = buildBlankAnnotatedString { }
             },
             onGroupItemSelected = {},
             selected = false,
@@ -66,7 +71,7 @@ fun main() = PreviewUtils.preview {
 }
 
 @Composable
-fun GroupsCard(
+fun GroupCard(
     item: GroupItem,
     onGroupItemSelected: (GroupItem) -> Unit,
     selected: Boolean,
@@ -75,7 +80,7 @@ fun GroupsCard(
         modifier = Modifier
             .fillMaxWidth()
             .defaultMinSize(minHeight = HEADER_SIZE)
-            .clickable(onClick = { onGroupItemSelected(item) })
+            .selectable(selected, onClick = { onGroupItemSelected(item) }, role = Role.Button)
             .semantics {
                 contentDescription =
                     if (selected) i18n("access.list.selected.yes")
@@ -90,7 +95,11 @@ fun GroupsCard(
         contentColor = MaterialTheme.colors.onSurface,
     ) {
         Row(
-            modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+            modifier = Modifier
+                .padding(horizontal = 8.dp, vertical = 4.dp)
+                .semantics {
+                    text = item.description
+                },
         ) {
             GroupCircle(item, modifier = Modifier.align(Alignment.Top).padding(vertical = 12.dp))
             Column(
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupCircle.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupCircle.kt
index 76874e0a7b..c3b244d259 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupCircle.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupCircle.kt
@@ -39,6 +39,7 @@ import androidx.compose.ui.unit.sp
 import org.briarproject.bramble.api.sync.GroupId
 import org.briarproject.briar.desktop.theme.outline
 import org.briarproject.briar.desktop.ui.NumberBadge
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.locale
 
 @Composable
 fun GroupCircle(item: GroupItem, showMessageCount: Boolean = true, modifier: Modifier = Modifier) {
@@ -54,7 +55,7 @@ fun GroupCircle(item: GroupItem, showMessageCount: Boolean = true, modifier: Mod
                 .background(item.id.getBackgroundColor()),
         ) {
             Text(
-                text = item.name.substring(0..0).uppercase(),
+                text = item.name.substring(0..0).uppercase(locale),
                 color = Color.White,
                 style = MaterialTheme.typography.body1.copy(
                     fontSize = 24.sp,
@@ -75,12 +76,12 @@ fun GroupCircle(item: GroupItem, showMessageCount: Boolean = true, modifier: Mod
 
 fun GroupId.getBackgroundColor(): Color {
     return Color(
-        red = getByte(bytes, 0) * 3 / 4 + 96,
-        green = getByte(bytes, 1) * 3 / 4 + 96,
-        blue = getByte(bytes, 2) * 3 / 4 + 96,
+        red = bytes.getByte(0) * 3 / 4 + 96,
+        green = bytes.getByte(1) * 3 / 4 + 96,
+        blue = bytes.getByte(2) * 3 / 4 + 96,
     )
 }
 
-private fun getByte(bytes: ByteArray, index: Int): Byte {
-    return bytes[index % bytes.size]
+private fun ByteArray.getByte(index: Int): Byte {
+    return this[index % size]
 }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupConversationScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupConversationScreen.kt
index 679d8a76f7..600b8fbd17 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupConversationScreen.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupConversationScreen.kt
@@ -60,7 +60,7 @@ import org.briarproject.briar.desktop.viewmodel.viewModel
 @Composable
 fun GroupConversationScreen(
     groupItem: GroupItem,
-    viewModel: ForumsViewModel = viewModel(),
+    viewModel: ForumViewModel = viewModel(),
 ) {
     val selectedPost = remember { mutableStateOf<MessageId?>(null) }
     Scaffold(
@@ -87,7 +87,7 @@ private fun GroupConversationHeader(
     groupItem: GroupItem,
     onGroupDelete: () -> Unit,
 ) {
-    val deleteGroupDialogState = remember { mutableStateOf(false) }
+    val deleteGroupDialogVisible = remember { mutableStateOf(false) }
     val menuState = remember { mutableStateOf(CLOSED) }
     val close = { menuState.value = CLOSED }
     Box(modifier = Modifier.fillMaxWidth().height(HEADER_SIZE + 1.dp)) {
@@ -130,7 +130,7 @@ private fun GroupConversationHeader(
                     DropdownMenuItem(
                         onClick = {
                             close()
-                            deleteGroupDialogState.value = true
+                            deleteGroupDialogVisible.value = true
                         }
                     ) {
                         Text(
@@ -143,9 +143,9 @@ private fun GroupConversationHeader(
         }
         HorizontalDivider(modifier = Modifier.align(BottomCenter))
     }
-    if (deleteGroupDialogState.value) {
+    if (deleteGroupDialogVisible.value) {
         DeleteForumDialog(
-            close = { deleteGroupDialogState.value = false },
+            close = { deleteGroupDialogVisible.value = false },
             onDelete = onGroupDelete,
         )
     }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupInputComposable.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupInputComposable.kt
index 152c39e77b..1b1b7120e9 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupInputComposable.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupInputComposable.kt
@@ -73,7 +73,7 @@ fun GroupInputComposable(
                     text = if (selectedPost.value == null) {
                         i18n("forum.message.hint")
                     } else {
-                        i18n("forum.message.replay.hint")
+                        i18n("forum.message.reply.hint")
                     },
                     style = MaterialTheme.typography.body1,
                 )
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsList.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupListComposable.kt
similarity index 88%
rename from briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsList.kt
rename to briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupListComposable.kt
index e90dd40aea..7ced0abe86 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumsList.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupListComposable.kt
@@ -39,17 +39,19 @@ import androidx.compose.runtime.Composable
 import androidx.compose.runtime.State
 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.sync.GroupId
-import org.briarproject.briar.desktop.contact.SearchTextField
 import org.briarproject.briar.desktop.theme.surfaceVariant
 import org.briarproject.briar.desktop.ui.Constants
 import org.briarproject.briar.desktop.ui.Constants.COLUMN_WIDTH
 import org.briarproject.briar.desktop.ui.HorizontalDivider
+import org.briarproject.briar.desktop.ui.SearchTextField
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
 
 @Composable
-fun ForumsList(
+fun GroupListComposable(
     list: State<List<GroupItem>>,
     isSelected: (GroupId) -> Boolean,
     filterBy: State<String>,
@@ -70,6 +72,7 @@ fun ForumsList(
                     placeholder = i18n("forum.search.title"),
                     icon = Icons.Filled.AddComment,
                     searchValue = filterBy.value,
+                    addButtonDescription = i18n("forum.add.title"),
                     onValueChange = onFilterSet,
                     onAddButtonClicked = onAddButtonClicked,
                 )
@@ -77,10 +80,13 @@ fun ForumsList(
             Box(modifier = Modifier.fillMaxSize()) {
                 LazyColumn(
                     state = scrollState,
-                    modifier = Modifier.selectableGroup()
+                    modifier = Modifier
+                        .semantics {
+                            contentDescription = i18n("access.forums.list")
+                        }.selectableGroup()
                 ) {
                     items(list.value) { item ->
-                        GroupsCard(
+                        GroupCard(
                             item = item,
                             onGroupItemSelected = onGroupItemSelected,
                             selected = isSelected(item.id)
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadItem.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadItem.kt
index bd1dc746ed..ee751e90dd 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadItem.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadItem.kt
@@ -55,21 +55,10 @@ abstract class ThreadItem(
     private var level: Int = UNDEFINED
     var isHighlighted = false
 
-    fun getLevel(): Int {
-        return level
-    }
-
-    override fun getId(): MessageId {
-        return messageId
-    }
-
-    override fun getParentId(): MessageId? {
-        return parentId
-    }
-
-    override fun getTimestamp(): Long {
-        return timestamp
-    }
+    override fun getId(): MessageId = messageId
+    override fun getParentId(): MessageId? = parentId
+    override fun getTimestamp(): Long = timestamp
+    fun getLevel(): Int = level
 
     /**
      * Returns the author's name, with an alias if one exists.
@@ -81,9 +70,7 @@ abstract class ThreadItem(
         this.level = level
     }
 
-    override fun hashCode(): Int {
-        return messageId.hashCode()
-    }
+    override fun hashCode(): Int = messageId.hashCode()
 
     override fun equals(other: Any?): Boolean {
         return other is ThreadItem && messageId == other.messageId
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threading/BriarExecutors.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threading/BriarExecutors.kt
index 545c12cd79..e18685288a 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threading/BriarExecutors.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threading/BriarExecutors.kt
@@ -30,6 +30,11 @@ interface BriarExecutors {
         @DatabaseExecutor task: (Transaction) -> Unit,
     )
 
+    suspend fun <T> runOnDbThread(
+        readOnly: Boolean,
+        @DatabaseExecutor task: (Transaction) -> T,
+    ): T
+
     fun onUiThread(@UiExecutor task: () -> Unit)
 
     fun onIoThread(@IoExecutor task: () -> Unit)
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threading/BriarExecutorsImpl.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threading/BriarExecutorsImpl.kt
index f002b48e0a..0700aa2092 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threading/BriarExecutorsImpl.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/threading/BriarExecutorsImpl.kt
@@ -20,6 +20,7 @@ package org.briarproject.briar.desktop.threading
 
 import mu.KotlinLogging
 import org.briarproject.bramble.api.db.DatabaseExecutor
+import org.briarproject.bramble.api.db.DbCallable
 import org.briarproject.bramble.api.db.Transaction
 import org.briarproject.bramble.api.db.TransactionManager
 import org.briarproject.bramble.api.lifecycle.IoExecutor
@@ -27,6 +28,8 @@ import org.briarproject.bramble.api.lifecycle.LifecycleManager
 import org.briarproject.briar.desktop.utils.KLoggerUtils.w
 import java.util.concurrent.Executor
 import javax.inject.Inject
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
 
 class BriarExecutorsImpl
 @Inject
@@ -75,6 +78,18 @@ constructor(
         }
     }
 
+    override suspend fun <T> runOnDbThread(
+        readOnly: Boolean,
+        @DatabaseExecutor task: (Transaction) -> T
+    ) = suspendCoroutine<T> { cont ->
+        // The coroutine suspends until the DatabaseExecutor has finished the task
+        // and ended the transaction. It then resumes with the returned value.
+        onDbThread {
+            val t = db.transactionWithResult(readOnly, DbCallable { txn -> task(txn) })
+            cont.resume(t)
+        }
+    }
+
     override fun onUiThread(@UiExecutor task: () -> Unit) = uiExecutor.execute(task)
 
     override fun onIoThread(@IoExecutor task: () -> Unit) = ioExecutor.execute(task)
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 43245ff456..0e2dfd36d7 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
@@ -21,7 +21,7 @@ package org.briarproject.briar.desktop.ui
 import androidx.compose.foundation.layout.Row
 import androidx.compose.runtime.Composable
 import org.briarproject.briar.desktop.conversation.PrivateMessageScreen
-import org.briarproject.briar.desktop.forums.ForumsScreen
+import org.briarproject.briar.desktop.forums.ForumScreen
 import org.briarproject.briar.desktop.navigation.BriarSidebar
 import org.briarproject.briar.desktop.navigation.SidebarViewModel
 import org.briarproject.briar.desktop.privategroups.PrivateGroupScreen
@@ -45,7 +45,7 @@ fun MainScreen(viewModel: SidebarViewModel = viewModel()) {
         when (viewModel.uiMode.value) {
             UiMode.CONTACTS -> PrivateMessageScreen()
             UiMode.GROUPS -> PrivateGroupScreen()
-            UiMode.FORUMS -> ForumsScreen()
+            UiMode.FORUMS -> ForumScreen()
             UiMode.SETTINGS -> SettingsScreen()
             UiMode.ABOUT -> AboutScreen()
             else -> UiPlaceholder()
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/SearchTextField.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/SearchTextField.kt
similarity index 93%
rename from briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/SearchTextField.kt
rename to briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/SearchTextField.kt
index 6d3e564dfb..b61a0466ab 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/SearchTextField.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/SearchTextField.kt
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-package org.briarproject.briar.desktop.contact
+package org.briarproject.briar.desktop.ui
 
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.padding
@@ -32,7 +32,6 @@ import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.unit.dp
-import org.briarproject.briar.desktop.ui.ColoredIconButton
 import org.briarproject.briar.desktop.utils.AccessibilityUtils.description
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
 
@@ -41,6 +40,7 @@ fun SearchTextField(
     placeholder: String,
     icon: ImageVector,
     searchValue: String,
+    addButtonDescription: String,
     onValueChange: (String) -> Unit,
     onAddButtonClicked: () -> Unit,
 ) {
@@ -61,7 +61,7 @@ fun SearchTextField(
             ColoredIconButton(
                 icon = icon,
                 iconSize = 20.dp,
-                contentDescription = i18n("access.contacts.add"),
+                contentDescription = addButtonDescription,
                 onClick = onAddButtonClicked,
                 modifier = Modifier.padding(end = 8.dp)
             )
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/DbViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/DbViewModel.kt
index b82cb09a2f..8547165856 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/DbViewModel.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/DbViewModel.kt
@@ -18,26 +18,18 @@
 
 package org.briarproject.briar.desktop.viewmodel
 
-import mu.KotlinLogging
 import org.briarproject.bramble.api.db.DatabaseExecutor
-import org.briarproject.bramble.api.db.DbCallable
 import org.briarproject.bramble.api.db.Transaction
 import org.briarproject.bramble.api.db.TransactionManager
 import org.briarproject.bramble.api.lifecycle.LifecycleManager
 import org.briarproject.briar.desktop.threading.BriarExecutors
-import kotlin.coroutines.resume
-import kotlin.coroutines.suspendCoroutine
 
 abstract class DbViewModel(
     private val briarExecutors: BriarExecutors,
     private val lifecycleManager: LifecycleManager,
-    private val db: TransactionManager
+    private val db: TransactionManager,
 ) : ViewModel {
 
-    companion object {
-        private val LOG = KotlinLogging.logger {}
-    }
-
     /**
      * Waits for the DB to open and runs the given [task] on the [DatabaseExecutor].
      * To avoid inconsistent state between the database and the UI
@@ -54,16 +46,15 @@ abstract class DbViewModel(
      */
     protected fun runOnDbThreadWithTransaction(
         readOnly: Boolean,
-        task: (Transaction) -> Unit
+        task: (Transaction) -> Unit,
     ) = briarExecutors.onDbThreadWithTransaction(readOnly, task)
 
+    /**
+     * Waits for the DB to open and runs the given [task] on the [DatabaseExecutor],
+     * returning its result.
+     */
     protected suspend fun <T> runOnDbThread(
         readOnly: Boolean,
-        task: (Transaction) -> T
-    ) = suspendCoroutine<T> { cont ->
-        briarExecutors.onDbThread {
-            val t = db.transactionWithResult(readOnly, DbCallable { txn -> task(txn) })
-            cont.resume(t)
-        }
-    }
+        @DatabaseExecutor task: (Transaction) -> T,
+    ): T = briarExecutors.runOnDbThread(readOnly, task)
 }
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 9f29948d41..bcb4353996 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
@@ -25,7 +25,7 @@ import dagger.multibindings.IntoMap
 import org.briarproject.briar.desktop.contact.ContactListViewModel
 import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel
 import org.briarproject.briar.desktop.conversation.ConversationViewModel
-import org.briarproject.briar.desktop.forums.ForumsViewModel
+import org.briarproject.briar.desktop.forums.ForumViewModel
 import org.briarproject.briar.desktop.introduction.IntroductionViewModel
 import org.briarproject.briar.desktop.login.StartupViewModel
 import org.briarproject.briar.desktop.navigation.SidebarViewModel
@@ -76,8 +76,8 @@ abstract class ViewModelModule {
 
     @Binds
     @IntoMap
-    @ViewModelKey(ForumsViewModel::class)
-    abstract fun bindForumsViewModel(forumsViewModel: ForumsViewModel): ViewModel
+    @ViewModelKey(ForumViewModel::class)
+    abstract fun bindForumsViewModel(forumViewModel: ForumViewModel): ViewModel
 
     @Binds
     @IntoMap
diff --git a/briar-desktop/src/main/resources/strings/BriarDesktop.properties b/briar-desktop/src/main/resources/strings/BriarDesktop.properties
index 28a7b003cd..0ccafe4e80 100644
--- a/briar-desktop/src/main/resources/strings/BriarDesktop.properties
+++ b/briar-desktop/src/main/resources/strings/BriarDesktop.properties
@@ -60,6 +60,9 @@ access.settings.click_to_toggle_notifications=Click to toggle notifications
 access.return_to_previous_screen=Return to previous screen
 access.menu=Show menu
 access.forums.add=Add forum
+access.forums.list=forum list
+access.forums.unread_count={0, plural, one {one unread posts} other {{0} unread posts}}
+access.forums.last_message_timestamp=last message: {0}
 
 # Contacts
 contacts.none_selected.title=No contact selected
@@ -115,7 +118,7 @@ forum.delete.dialog.title=Confirm Leaving Forum
 forum.delete.dialog.message=Are you sure that you want to leave this forum?\n\nAny contacts you\'ve shared this forum with might stop receiving updates.
 forum.delete.dialog.button=Leave
 forum.message.hint=New Post
-forum.message.replay.hint=New Reply
+forum.message.reply.hint=New Reply
 group.card.no_posts=No posts
 group.card.posts={0, plural, one {{0} post} other {{0} posts}}
 
-- 
GitLab