From ec51d4020c4c533ad15cd3fd4402ba2747f6d972 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 03:30:26 +0000 Subject: [PATCH] feat(concord): stock-relay list import + single-screen community/channel browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things that make the Armada-interop scenario actually usable: - Bootstrap from the Concord stock relays: Account.importConcordCommunitiesFromStockRelays fetches this account's kind-13302 joined list from InviteRelayDictionary.STOCK (where the reference client publishes it — e.g. relay.ditto.pub — not the user's outbox) and folds the newest into LocalCache. Triggered on Concord-hub open (scoped so only Concord users reach those relays). Read-only/newest-wins, safe to repeat. This is why a community joined on Armada never showed up before: Amethyst only queried the user's own relays. - The hub is now a single-screen browser: a community rail across the top plus an expandable accordion where each community reveals its #/lock/mic channels inline — browse community-first, then channels, without leaving the screen. Mounts the live plane subscription so channels fold in while browsing; tapping a channel opens its chat, the community avatar opens the full server view. Still open for true two-client parity: verifying the 13302 encrypted-list JSON schema against a real Armada-written event (field-name decode) and union-merging on write. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 25 +++ .../ui/screen/loggedIn/AccountViewModel.kt | 6 + .../concord/ConcordHomeScreen.kt | 194 +++++++++++++++--- 3 files changed, 196 insertions(+), 29 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 4cdad3b244..2aeafc7c84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -136,10 +136,12 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentF import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent @@ -2149,6 +2151,29 @@ class Account( return wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } } + /** + * Bootstrap the Concord hub from the network: fetch this account's kind-13302 + * joined-communities list from the Concord stock relays (where the reference + * client — Armada/Vector — publishes it, e.g. relay.ditto.pub) and fold the + * newest into [LocalCache], so communities we joined on another Concord client + * with this key surface here. Our outbox never carries that list, so without + * this a community joined on Armada would never appear. + * + * Read-only import: kind 13302 is replaceable, so folding an older copy is a + * no-op and this is safe to call on every hub open. Merging our own edits with + * a foreign writer's is a separate concern (newest-wins replaceable). + */ + suspend fun importConcordCommunitiesFromStockRelays() { + val relays = InviteRelayDictionary.STOCK.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isEmpty()) return + val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(signer.pubKey)) + val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }) + events + .filterIsInstance() + .maxByOrNull { it.createdAt } + ?.let { cache.justConsumeMyOwnEvent(it) } + } + // ── NIP-29 relay-group actions ─────────────────────────────────────────── // All group commands are published ONLY to the group's host relay, where // relay29 authorizes them. The relay is the source of truth; the kind-10009 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 1fafa63d2d..59ea57b5ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -623,6 +623,12 @@ class AccountViewModel( if (ban) account.banConcordMember(communityId, member) else account.unbanConcordMember(communityId, member) } + /** Pull the account's Concord community list from the stock relays (Concord hub bootstrap). */ + fun importConcordCommunities() = + viewModelScope.launch(Dispatchers.IO) { + account.importConcordCommunitiesFromStockRelays() + } + @Immutable data class NoteComposeReportState( val isPostHidden: Boolean = false, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index 7db61c725f..058e0b9148 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -20,15 +20,19 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord +import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api @@ -40,30 +44,45 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon /** - * The Concord Channels hub: lists the communities the account has joined (from the - * kind-13302 list) and offers a Create action. Concord has no public directory — - * communities are E2E-encrypted and invite-gated by design — so there is no browse - * feed here; you arrive at a community by creating one or redeeming an invite link. + * The Concord Channels hub — a single-screen browser of every community the account + * joined (kind-13302) and, expanded inline, that community's channels. A community + * rail across the top jumps to (and expands) any server; each row in the list below + * is a community you can expand to reveal its `#`/🔒/🎙 channels without leaving the + * screen. Tapping a channel opens its chat; the community header opens the full + * server view. + * + * Concord has no public directory — communities are E2E-encrypted and invite-gated — + * so there's no browse feed: you arrive by creating one, redeeming an invite, or (for + * a key already used on another Concord client) the on-open import from the stock + * relays below. The live plane subscription is mounted here so channels fold in while + * you browse. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -71,11 +90,22 @@ fun ConcordHomeScreen( accountViewModel: AccountViewModel, nav: INav, ) { + ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + val account = accountViewModel.account val communities by account.concordChannelList.liveCommunities.collectAsStateWithLifecycle() - // Re-read folded metadata (icon / channel count) whenever a Control Plane folds. + // Re-read folded metadata (name / icon / channels) whenever a Control Plane folds. val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + // Concord clients (Armada/Vector) publish the kind-13302 joined list to the Concord + // stock relays, not the user's outbox, so a community joined there never surfaces at + // login. Pull it from those relays when the hub opens — scoped here so we only reach + // the stock relays for users who actually use Concord. + LaunchedEffect(Unit) { accountViewModel.importConcordCommunities() } + + // Communities expanded in the accordion (multi-open, so several can show channels at once). + var expanded by remember { mutableStateOf(emptySet()) } + Scaffold( topBar = { TopAppBar( @@ -106,25 +136,62 @@ fun ConcordHomeScreen( modifier = Modifier.padding(horizontal = 32.dp), ) } - } else { - LazyColumn(Modifier.fillMaxSize().padding(padding)) { - items(communities, key = { it.id }) { entry -> + return@Scaffold + } + + Column(Modifier.fillMaxSize().padding(padding)) { + // Community rail: every joined community as an avatar; tap toggles its channels below. + CommunityRail( + communities = communities, + revision = revision, + expanded = expanded, + accountViewModel = accountViewModel, + onToggle = { id -> expanded = if (id in expanded) expanded - id else expanded + id }, + ) + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + + LazyColumn(Modifier.fillMaxSize()) { + communities.forEach { entry -> val state = - remember(entry.id, revision) { - account.concordSessions - .sessionFor(entry.id) - ?.state - ?.value + account.concordSessions + .sessionFor(entry.id) + ?.state + ?.value + .takeIf { revision >= 0 } + val isOpen = entry.id in expanded + + item(key = entry.id) { + CommunityHeader( + communityId = entry.id, + name = state?.metadata?.name?.takeIf { it.isNotBlank() } ?: entry.name.ifBlank { stringRes(R.string.concord_home_title) }, + iconUrl = state?.metadata?.icon, + channelCount = state?.channels?.size ?: 0, + expanded = isOpen, + accountViewModel = accountViewModel, + onToggle = { expanded = if (isOpen) expanded - entry.id else expanded + entry.id }, + onOpen = { nav.nav(Route.ConcordServer(entry.id)) }, + ) + } + + if (isOpen && state != null) { + val channels = state.channels.entries.toList() + items(channels, key = { "${entry.id}/${it.key}" }) { ch -> + val def = ch.value.definition + ChannelSubRow( + name = def?.name ?: ch.key, + icon = + when { + def?.voice == true -> MaterialSymbols.Mic + def?.private == true -> MaterialSymbols.Lock + else -> MaterialSymbols.Tag + }, + onClick = { nav.nav(Route.Concord(entry.id, ch.key)) }, + ) } - CommunityRow( - communityId = entry.id, - name = state?.metadata?.name?.takeIf { it.isNotBlank() } ?: entry.name.ifBlank { stringRes(R.string.concord_home_title) }, - iconUrl = state?.metadata?.icon, - channelCount = state?.channels?.size ?: 0, - accountViewModel = accountViewModel, - onClick = { nav.nav(Route.ConcordServer(entry.id)) }, - ) - HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + } + item(key = "div-${entry.id}") { + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + } } } } @@ -132,27 +199,70 @@ fun ConcordHomeScreen( } @Composable -private fun CommunityRow( +private fun CommunityRail( + communities: List, + revision: Int, + expanded: Set, + accountViewModel: AccountViewModel, + onToggle: (String) -> Unit, +) { + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + LazyRow( + Modifier.fillMaxWidth().padding(vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + items(communities, key = { it.id }) { entry -> + val iconUrl = + accountViewModel.account.concordSessions + .sessionFor(entry.id) + ?.state + ?.value + ?.metadata + ?.icon + .takeIf { revision >= 0 } + val isOpen = entry.id in expanded + val ring = if (isOpen) MaterialTheme.colorScheme.primary else Color.Transparent + RobohashFallbackAsyncImage( + robot = entry.id, + model = iconUrl, + contentDescription = entry.name, + modifier = + Modifier + .size(48.dp) + .clip(CircleShape) + .border(2.dp, ring, CircleShape) + .clickable { onToggle(entry.id) }, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = autoPlayGif, + ) + } + } +} + +@Composable +private fun CommunityHeader( communityId: String, name: String, iconUrl: String?, channelCount: Int, + expanded: Boolean, accountViewModel: AccountViewModel, - onClick: () -> Unit, + onToggle: () -> Unit, + onOpen: () -> Unit, ) { val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() Row( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 12.dp), + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = - androidx.compose.foundation.layout.Arrangement - .spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { RobohashFallbackAsyncImage( robot = communityId, model = iconUrl, contentDescription = name, - modifier = Modifier.size(40.dp).clip(CircleShape), + modifier = Modifier.size(40.dp).clip(CircleShape).clickable(onClick = onOpen), loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), autoPlayGif = autoPlayGif, @@ -167,5 +277,31 @@ private fun CommunityRow( ) } } + SymbolIcon( + symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun ChannelSubRow( + name: String, + icon: MaterialSymbol, + onClick: () -> Unit, +) { + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(start = 40.dp, end = 16.dp) + .padding(vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SymbolIcon(symbol = icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Text(name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) } }