mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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) }
|
||||
|
||||
+12
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+7
@@ -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)
|
||||
|
||||
+144
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
@@ -68,6 +68,7 @@ enum class NavBarItem {
|
||||
RELAY_GROUPS,
|
||||
CONCORD,
|
||||
MARMOT_GROUPS,
|
||||
CORDN_GROUPS,
|
||||
GEOHASH_CHATS,
|
||||
FOLLOW_PACKS,
|
||||
LIVE_STREAMS,
|
||||
|
||||
+2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user