diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index c0a07d3837..cb32a317ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { CordnGroupInfoScreen(it.coordinatorPubKey, it.gid, accountViewModel, nav) } + composableFromEnd { CordnGroupListScreen(accountViewModel, nav) } composableFromBottom { CordnCreateGroupScreen(accountViewModel, nav) } composableFromBottom { CordnCreateMembersScreen(accountViewModel, nav) } composableFromEnd { CordnInvitationsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 0e2bb228fe..b649c9da45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -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 = 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 = NavBarItem.RELAY_GROUPS, NavBarItem.CONCORD, NavBarItem.MARMOT_GROUPS, + NavBarItem.CORDN_GROUPS, NavBarItem.GEOHASH_CHATS, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt index 378e698010..5deaaacad1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt @@ -153,6 +153,7 @@ private val DrawerFeedsItems: List = NavBarItem.RELAY_GROUPS, NavBarItem.CONCORD, NavBarItem.MARMOT_GROUPS, + NavBarItem.CORDN_GROUPS, NavBarItem.GEOHASH_CHATS, NavBarItem.CALENDARS, NavBarItem.CALENDAR_COLLECTIONS, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 5275a0cf0e..585669b95c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt new file mode 100644 index 0000000000..71254d98b7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt @@ -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()) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 5cde7e0866..8b2ba48747 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -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, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 61fd0bb850..6b455186d6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -305,6 +305,9 @@ + No cordn groups yet + A cordn group lives on one coordinator that holds its membership and passes messages along, without ever seeing what anyone writes. + Start one This group is not available. Its coordinator may have been removed. Group info Message deleted diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/NavBarItem.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/NavBarItem.kt index 14fea0e82d..e2c745f525 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/NavBarItem.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/NavBarItem.kt @@ -68,6 +68,7 @@ enum class NavBarItem { RELAY_GROUPS, CONCORD, MARMOT_GROUPS, + CORDN_GROUPS, GEOHASH_CHATS, FOLLOW_PACKS, LIVE_STREAMS, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt index 798d35ca42..70b8daf092 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt @@ -653,6 +653,8 @@ sealed class Route { val gid: String, ) : Route() + @Serializable object CordnGroupList : Route() + @Serializable object CordnCreateGroup : Route() @Serializable object CordnCreateGroupMembers : Route() diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 0b4831993c..a0b587b986 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2911,6 +2911,7 @@ You don't pass this community's web-of-trust requirements. The latest community rules document was rejected as stale. Marmot Groups + Cordn Groups Create Group Create Marmot Group A new MLS group will be created. You can add members after.