mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+165
-29
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user