feat(concord): stock-relay list import + single-screen community/channel browser

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-11 03:30:26 +00:00
parent c4657d482e
commit ec51d4020c
3 changed files with 196 additions and 29 deletions
@@ -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<ConcordCommunityListEvent>()
.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
@@ -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,
@@ -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<String>()) }
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<ConcordCommunityListEntry>,
revision: Int,
expanded: Set<String>,
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)
}
}