feat(cordn): a Cordn Groups entry in the drawer and the tab catalog

The Feeds section listed NavBarItem.MARMOT_GROUPS and had no cordn equivalent,
so the one conversation type whose messages do not come from a relay was the
only one with no way to see it apart from the mixed Messages inbox. Adds
NavBarItem.CORDN_GROUPS, which puts it in the drawer beside the Marmot rooms
and makes it pinnable as a bottom-bar tab like every other entry.

Route.CordnGroupList opens a list of the groups this account is in. It exists
alongside the inbox rather than duplicating it: the inbox mixes every type,
which is right for reading and wrong for finding, and for a cordn group the
question when something stops arriving is always "which coordinator" rather
than "which relay".

It renders from local state alone -- CordnGroupList.all, which CordnRuntime
already holds -- so BottomBarFeedPreloaders has nothing to warm for it, for a
different reason than Marmot's: a cordn message never arrives over a relay REQ
at all, it arrives as a coordinator call the sync loop already makes. Ordered
off the same `revision` signal the inbox ordering uses, because a room's newest
message changes inside the room object and ordering on `all` alone would freeze
after the first message.

Reuses the inbox's own CordnGroupRoomCompose (now internal) rather than a
second rendering of the same row: it already reads the live name, preview,
annotations and unread count off the room, and two of those drifting apart
would be a bug nobody would look for in a list screen.

Icon is Dns, matching every other cordn surface -- a coordinator is a server,
and that is what distinguishes these rows from the Marmot ones above them.

Not exercised on a device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
Claude
2026-09-26 14:44:09 +00:00
parent bfa187a70f
commit 0ff200ec35
10 changed files with 174 additions and 1 deletions
@@ -132,6 +132,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnCreat
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnCreateMembersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnGroupChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnGroupInfoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnGroupListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnInvitationsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashChatsScreen
@@ -730,6 +731,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.CordnGroupInfo> {
CordnGroupInfoScreen(it.coordinatorPubKey, it.gid, accountViewModel, nav)
}
composableFromEnd<Route.CordnGroupList> { CordnGroupListScreen(accountViewModel, nav) }
composableFromBottom<Route.CordnCreateGroup> { CordnCreateGroupScreen(accountViewModel, nav) }
composableFromBottom<Route.CordnCreateGroupMembers> { CordnCreateMembersScreen(accountViewModel, nav) }
composableFromEnd<Route.CordnInvitations> { CordnInvitationsScreen(accountViewModel, nav) }
@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.resources.bottom_bar_category_you
import com.vitorpamplona.amethyst.commons.resources.browser
import com.vitorpamplona.amethyst.commons.resources.communities
import com.vitorpamplona.amethyst.commons.resources.concord_home_title
import com.vitorpamplona.amethyst.commons.resources.cordn_groups_title
import com.vitorpamplona.amethyst.commons.resources.discover_marketplace
import com.vitorpamplona.amethyst.commons.resources.discover_reads
import com.vitorpamplona.amethyst.commons.resources.drafts
@@ -391,6 +392,16 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Lock,
resolveRoute = { Route.MarmotGroupList },
),
NavBarItem.CORDN_GROUPS to
NavBarItemDef(
id = NavBarItem.CORDN_GROUPS,
labelRes = Res.string.cordn_groups_title,
// Dns, the symbol every other cordn surface uses: a coordinator
// is a server, and that is the one thing that distinguishes
// these from the Marmot rooms directly above.
icon = MaterialSymbols.Dns,
resolveRoute = { Route.CordnGroupList },
),
NavBarItem.GEOHASH_CHATS to
NavBarItemDef(
id = NavBarItem.GEOHASH_CHATS,
@@ -527,6 +538,7 @@ val BottomBarCategories: List<NavBarCategory> =
NavBarItem.RELAY_GROUPS,
NavBarItem.CONCORD,
NavBarItem.MARMOT_GROUPS,
NavBarItem.CORDN_GROUPS,
NavBarItem.GEOHASH_CHATS,
),
),
@@ -153,6 +153,7 @@ private val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.RELAY_GROUPS,
NavBarItem.CONCORD,
NavBarItem.MARMOT_GROUPS,
NavBarItem.CORDN_GROUPS,
NavBarItem.GEOHASH_CHATS,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
@@ -160,6 +160,13 @@ private fun PreloadFor(
// to warm up, and adding one would duplicate those.
NavBarItem.MARMOT_GROUPS -> Unit
// Same as Marmot, for a different reason: a cordn group's messages never
// arrive over a relay REQ at all. They are coordinator calls made by
// CordnRuntime's sync loop, which is already running for every
// coordinator this account holds -- there is no relay subscription a
// list could warm up.
NavBarItem.CORDN_GROUPS -> Unit
NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel)
NavBarItem.LIVE_STREAMS -> LiveStreamsFilterAssemblerSubscription(accountViewModel)
@@ -0,0 +1,144 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cordn_groups_title
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.CordnGroupRoomCompose
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Every cordn group this account is in.
*
* ## Why this exists when Messages already lists them
*
* Messages mixes every conversation type into one inbox, which is right for
* reading and wrong for finding: a cordn group is the one kind whose messages
* do not come from a relay, so when something is not arriving the question is
* always "which coordinator" rather than "which relay". A list of just these,
* reachable from the drawer like the Marmot rooms beside it, is where that
* question can be answered. Tapping a row opens the same chat the inbox opens.
*
* ## It renders from local state alone
*
* `CordnGroupList.all` is the rooms `CordnRuntime` already holds, and its sync
* loop already subscribes per coordinator. There is no relay REQ behind this
* screen and no list-level fetch to make -- which is why
* `BottomBarFeedPreloaders` has nothing to warm for it. Ordered by the newest
* message, off the same `revision` signal the inbox ordering uses, so a group
* that has just received something rises here too.
*/
@Composable
fun CordnGroupListScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val runtime = accountViewModel.account.cordnRuntime
Scaffold(
topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_groups_title), nav) },
) { padding ->
if (runtime == null) {
EmptyState(
title = stringRes(R.string.cordn_group_unavailable),
description = stringRes(R.string.cordn_group_unavailable_detail),
modifier = Modifier.padding(padding),
)
return@Scaffold
}
// `revision` rather than `all`: a room's newest message changes inside
// the room object, which `all` cannot see, so ordering on `all` alone
// would freeze after the first message. See CordnGroupList.revision.
val revision by runtime.groups.revision.collectAsStateWithLifecycle()
val rooms =
remember(revision) {
runtime.groups.all.value
.sortedByDescending {
it.newest.value
?.envelope
?.createdAt ?: 0L
}
}
if (rooms.isEmpty()) {
Column(
modifier = Modifier.padding(padding).fillMaxSize().padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringRes(R.string.cordn_groups_none),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringRes(R.string.cordn_groups_none_detail),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(onClick = { nav.nav(Route.CordnCreateGroup) }) {
Text(stringRes(R.string.cordn_groups_start))
}
}
return@Scaffold
}
LazyColumn(
modifier = Modifier.padding(padding).fillMaxSize(),
state = rememberLazyListState(),
) {
items(rooms, key = { it.coordinatorPubKey + it.gid }) { room ->
// The inbox's own row, not a second rendering of it: it already
// reads the live name, preview, annotations and unread count off
// the room, and two of those going out of step would be a bug
// nobody would look for here.
CordnGroupRoomCompose(room, accountViewModel, nav)
HorizontalDivider(Modifier.fillMaxWidth())
}
}
}
}
@@ -582,7 +582,7 @@ private fun attachmentLabelFor(tags: TagArray): StringResource {
* is on the room. Reading the Note would render an empty row forever.
*/
@Composable
private fun CordnGroupRoomCompose(
internal fun CordnGroupRoomCompose(
chatroom: CordnGroupChatroom,
accountViewModel: AccountViewModel,
nav: INav,
+3
View File
@@ -305,6 +305,9 @@
<string name="cordn_groups_none">No cordn groups yet</string>
<string name="cordn_groups_none_detail">A cordn group lives on one coordinator that holds its membership and passes messages along, without ever seeing what anyone writes.</string>
<string name="cordn_groups_start">Start one</string>
<string name="cordn_group_unavailable">This group is not available. Its coordinator may have been removed.</string>
<string name="cordn_group_info">Group info</string>
<string name="cordn_message_deleted">Message deleted</string>
@@ -68,6 +68,7 @@ enum class NavBarItem {
RELAY_GROUPS,
CONCORD,
MARMOT_GROUPS,
CORDN_GROUPS,
GEOHASH_CHATS,
FOLLOW_PACKS,
LIVE_STREAMS,
@@ -653,6 +653,8 @@ sealed class Route {
val gid: String,
) : Route()
@Serializable object CordnGroupList : Route()
@Serializable object CordnCreateGroup : Route()
@Serializable object CordnCreateGroupMembers : Route()
@@ -2911,6 +2911,7 @@
<string name="community_rules_violation_wot_gate_failed">You don't pass this community's web-of-trust requirements.</string>
<string name="community_rules_violation_stale_rules">The latest community rules document was rejected as stale.</string>
<string name="marmot_groups_title">Marmot Groups</string>
<string name="cordn_groups_title">Cordn Groups</string>
<string name="marmot_create_group">Create Group</string>
<string name="marmot_create_group_title">Create Marmot Group</string>
<string name="marmot_create_group_footer">A new MLS group will be created. You can add members after.</string>