feat(concord): Direct Invite UI and amy verbs (CORD-05 §6)

- commons: ConcordActions.draftDirectInvite holds the send-side refusals
  (dissolved, banned sender, banned or invalid recipient) so the app and amy
  share them.
- commonsUI: "Invite by npub" dialog (user typeahead) and a pending
  Direct Invites card (bundle name + robohash preview, no icon or profile
  fetch, Accept / Decline), with new strings in commonsUI resources.
- amethyst: the dialog behind the community overflow menu; the invites card
  on the Concord hub, including its empty state.
- amy: `concord invite COMMUNITY --to USER [--expires-in SECS]`,
  `concord invites`, `concord accept WRAP-ID`, `concord decline WRAP-ID`;
  the link join is factored into joinBundle and shared with accept.
- Conformance review: F6 fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N
This commit is contained in:
Claude
2026-09-29 18:30:25 +00:00
parent d73348d19b
commit 0cfcec6d56
15 changed files with 736 additions and 74 deletions
@@ -87,6 +87,7 @@ import com.vitorpamplona.amethyst.commons.resources.concord_channel_no_messages
import com.vitorpamplona.amethyst.commons.resources.concord_channel_rename
import com.vitorpamplona.amethyst.commons.resources.concord_channel_rename_save
import com.vitorpamplona.amethyst.commons.resources.concord_channels_empty
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_action
import com.vitorpamplona.amethyst.commons.resources.concord_edit_title
import com.vitorpamplona.amethyst.commons.resources.concord_invite_action
import com.vitorpamplona.amethyst.commons.resources.concord_invite_links_action
@@ -108,6 +109,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.commons.ui.note.timeAgo
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordDirectInviteDialog
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreviewLoader
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -179,6 +181,11 @@ fun ConcordChannelListScreen(
// Read once here (it is @Composable) so the post-leave navigation can use it from a callback.
val canPop = nav.canPop()
var showLeave by remember { mutableStateOf(false) }
var showDirectInvite by remember { mutableStateOf(false) }
if (showDirectInvite) {
ConcordDirectInviteDialog(communityId, accountViewModel, onDismiss = { showDirectInvite = false })
}
if (showLeave) {
ConcordLeaveDialog(
@@ -327,6 +334,16 @@ fun ConcordChannelListScreen(
SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(Res.string.more_options))
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
// A Direct Invite (CORD-05 §6) hands keys to one known npub. No permission gates
// it — none could, any keyholder can whisper keys — so neither does this item;
// what it carries is bounded by the recipient's roles instead.
DropdownMenuItem(
text = { Text(stringRes(Res.string.concord_direct_invite_action)) },
onClick = {
menuOpen = false
showDirectInvite = true
},
)
// Deliberately not gated on CREATE_INVITE, unlike minting: the links listed
// there are this account's own, authored by link-signer keys only we hold.
// Gating on the bit would mean a demoted admin could no longer retire the
@@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.timeAgo
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordPendingDirectInvites
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -158,13 +159,18 @@ fun ConcordHomeScreen(
},
) { padding ->
if (communities.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text(
stringRes(Res.string.concord_home_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 32.dp),
)
// Direct Invites (CORD-05 §6) are how a first community usually arrives, so they show
// above the empty state rather than being hidden by it.
Column(Modifier.fillMaxSize().padding(padding)) {
ConcordPendingDirectInvites(accountViewModel, nav)
Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {
Text(
stringRes(Res.string.concord_home_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 32.dp),
)
}
}
return@Scaffold
}
@@ -187,6 +193,9 @@ fun ConcordHomeScreen(
}
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
// Pending Direct Invites (CORD-05 §6), parked until the user accepts or declines.
item(key = "concord-direct-invites") { ConcordPendingDirectInvites(accountViewModel, nav) }
sorted.forEach { entry ->
val state =
account.concordSessions
+5
View File
@@ -680,6 +680,10 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List
| `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). A private channel posts on its own key's plane; without a held key it fails with `no_channel_key` instead of falling back to the community-wide plane. |
| `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane (public channels; a private channel reads its held key's plane). |
| `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link (at most 3 bootstrap relays ride in the fragment, CORD-05 §3; the bundle names this account as creator). |
| `amy concord invite COMMUNITY --to USER [--expires-in SECS]` | Send a Direct Invite (CORD-05 §6): the bundle giftwrapped as standard NIP-59 (kind-3313 rumor, `k=3313` wrap tag, NIP-40 expiration when `--expires-in` is set) to USER (npub, hex, nprofile or NIP-05) on their kind-10050 relays, else NIP-65 read relays, else the stock set. Carries only the private-channel keys USER's roles grant; refused for a banned recipient. No registry entry, never flips the community Public, cannot be revoked. |
| `amy concord invites` | List Direct Invites waiting for this account (sender, community name/icon, expired, catch-up). Read-only: nothing joins or contacts the community's relays. Communities you already hold are hidden unless the invite carries new channel keys on the same base (a catch-up). |
| `amy concord accept WRAP-ID` | Accept a Direct Invite (full wrap id or a unique prefix): the same join path as a link (ban-gated, Guestbook Join attributed to the seal-verified sender); refused past `expires_at`. For a community you hold, only adopts newly granted private-channel keys on the same root/epoch/control_pk, never moving the base. |
| `amy concord decline WRAP-ID` | Discard a Direct Invite; its wrap id is remembered in `concord-invites.json` so it never resurfaces. |
| `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, then records it in your Invite List. |
| `amy concord join URL` | Redeem an invite link, save the community, and publish a Guestbook Join echoing the link's attribution (CORD-05 §1/§6). |
| `amy concord rekey [COMMUNITY]` | Follow a Refounding we were re-keyed for. Honors only a BAN-holding rotator whose `vac` cites a Grant our fold has synced (the owner cites none); racing rotations converge on the lowest root (CORD-06 §3). |
@@ -987,6 +991,7 @@ matches that:
│ ├── aliases.json # local name → npub map
│ ├── cashu.json # NIP-60 NUT-13 counters
│ ├── concord.json # Concord community secrets
│ ├── concord-invites.json # declined Concord Direct Invite wrap ids
│ └── marmot/ # MLS state per group
└── bob/
└── …
@@ -224,6 +224,7 @@ class DataDir(
val aliasesFile = File(root, "aliases.json")
val cashuFile = File(root, "cashu.json")
val concordFile = File(root, "concord.json")
val concordInvitesFile = File(root, "concord-invites.json")
val marmotDir = File(root, "marmot")
val groupsDir = File(marmotDir, "groups")
val keyPackageBundleFile = File(marmotDir, "keypackages.bundle")
@@ -884,6 +884,9 @@ private fun printUsage() {
| concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id)
| concord read COMMUNITY CHANNEL [--limit N] read a channel's messages
| concord invite COMMUNITY [--base URL] mint + publish a shareable invite link
| concord invite COMMUNITY --to USER send a Direct Invite (giftwrapped bundle)
| concord invites list Direct Invites waiting for you
| concord accept|decline WRAP-ID join from / discard a Direct Invite
| concord revoke COMMUNITY TOKEN|URL retire a link you minted (vsk=9 tombstone)
| concord join URL redeem an invite link and save the community
|
@@ -172,7 +172,7 @@ object ConcordChannelCommands {
}
/** Drain the control plane and fold it into the current community state. */
private suspend fun foldState(
suspend fun foldState(
ctx: Context,
sc: StoredCommunity,
): ConcordCommunityState {
@@ -24,12 +24,18 @@ import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.stores.ConcordInviteInboxStore
import com.vitorpamplona.amethyst.cli.stores.ConcordStore
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot
import com.vitorpamplona.amethyst.cli.stores.StoredPrivateChannel
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft
import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteSendResult
import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteInbox
import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteView
import com.vitorpamplona.amethyst.commons.model.concord.DirectInviteAcceptPlan
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
@@ -41,6 +47,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
@@ -49,6 +56,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -75,6 +83,14 @@ object ConcordCommands {
| concord read COMMUNITY CHANNEL [--limit N] read a channel's messages (default 50);
| [--epoch N] [--root HEX] --epoch/--root read a prior epoch's plane
| concord invite COMMUNITY [--base URL] mint + publish a shareable invite link
| concord invite COMMUNITY --to USER send a Direct Invite (CORD-05 §6): the bundle
| [--expires-in SECS] giftwrapped to USER (npub|hex|nprofile|nip05),
| to their 10050 / NIP-65 read / stock relays,
| with only the private channels their roles grant
| concord invites list Direct Invites waiting for you (never joins)
| concord accept WRAP-ID accept a Direct Invite: join (or, for a community
| you hold, adopt newly granted channel keys)
| concord decline WRAP-ID discard a Direct Invite; it never resurfaces
| concord revoke COMMUNITY TOKEN|URL retire a link you minted: publishes a vsk=9
| tombstone at its coordinate, then tombstones
| it in your invite list so it stays retired
@@ -105,7 +121,7 @@ object ConcordCommands {
route(
"concord",
tail,
"concord <create|list|import|channels|send|read|invite|revoke|join|recover|rekey|roles|role|grant|ban|unban|refound|dissolve>",
"concord <create|list|import|channels|send|read|invite|invites|accept|decline|revoke|join|recover|rekey|roles|role|grant|ban|unban|refound|dissolve>",
help = USAGE,
routes =
mapOf(
@@ -116,6 +132,9 @@ object ConcordCommands {
"send" to { rest -> ConcordChannelCommands.send(dataDir, rest) },
"read" to { rest -> ConcordChannelCommands.read(dataDir, rest) },
"invite" to { rest -> invite(dataDir, rest) },
"invites" to { rest -> invites(dataDir, rest) },
"accept" to { rest -> accept(dataDir, rest) },
"decline" to { rest -> decline(dataDir, rest) },
"revoke" to { rest -> revoke(dataDir, rest) },
"join" to { rest -> join(dataDir, rest) },
"recover" to { rest -> recover(dataDir, rest) },
@@ -282,9 +301,13 @@ object ConcordCommands {
val args = Args(rest)
val handle = args.positional(0, "community")
val base = args.flag("base", "https://vector.chat")!!
val to = args.flag("to")
val expiresInSecs = args.flag("expires-in")?.let { it.toLongOrNull()?.takeIf { secs -> secs > 0 } ?: throw IllegalArgumentException("--expires-in expects a positive number of seconds, got '$it'") }
args.rejectUnknown()
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return notFound(handle)
if (to != null) return directInvite(dataDir, sc, to, expiresInSecs)
if (expiresInSecs != null) return Output.error("bad_args", "--expires-in applies to a Direct Invite (--to)").let { 2 }
Context.open(dataDir).use { ctx ->
ctx.prepare()
// The joiner cannot derive the Control Plane address, so the invite carries it
@@ -447,62 +470,264 @@ object ConcordCommands {
InviteBundleStatus.Absent -> return Output.error("not_found", "no bundle for this link on any of its relays")
}
// Refuse a link that readmits us after we were removed. A Refounding re-mints every
// outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its
// unlock token forever — so without this check the rotation that was supposed to expel
// them hands them the new keys instead. `recover` has always been ban-gated; `join` is
// the other door into the same room.
//
// Fails CLOSED on an unreadable plane: no verdict, no join. The banlist is only knowable
// after the bundle yields the root, which is why the check lives here rather than before.
val joinKeys =
ConcordActions.controlPlaneKeys(
communityRoot = bundle.communityRoot.hexToByteArray(),
communityId = bundle.communityId.hexToByteArray(),
rootEpoch = bundle.rootEpoch,
controlPk = bundle.controlPk,
)
val joinRelays = normalize(bundle.relays).ifEmpty { relays }
val joinEditions =
ConcordActions.controlEditions(
ctx.drain(joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }, pendingOnAuthRequired = true).map { it.second },
joinKeys,
)
if (joinEditions.isEmpty()) {
return Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so whether it has banned you is unknown — refusing to join")
}
if (AuthorityResolver.resolve(joinEditions, bundle.communityId.hexToByteArray(), bundle.owner).isBanned(ctx.signer.pubKey)) {
return Output.error("banned", "this community has banned this account; the link works but the roster does not admit you (CORD-04)")
}
return joinBundle(
ctx = ctx,
dataDir = dataDir,
bundle = bundle,
fallbackRelays = relays,
// The stranded-recovery anchor: if a later Refounding leaves us out, re-resolving
// this link is the only way back (CORD-05/06). Stored bare, domain-agnostic.
inviteRef = ConcordActions.bareInviteRef(url) ?: "",
inviteCreator = bundle.creatorNpub,
inviteLabel = bundle.label,
)
}
}
val stored =
StoredCommunity(
name = bundle.name,
communityId = bundle.communityId,
owner = bundle.owner,
ownerSalt = bundle.ownerSalt,
root = bundle.communityRoot,
rootEpoch = bundle.rootEpoch,
// Read access to the Control Plane, never write (CORD-05 §1). Absent = the
// community is still pre-split and folds at the legacy address.
controlPk = bundle.controlPk ?: "",
relays = bundle.relays,
// The stranded-recovery anchor: if a later Refounding leaves us out, re-resolving
// this link is the only way back (CORD-05/06). Stored bare, domain-agnostic.
inviteRef = ConcordActions.bareInviteRef(url) ?: "",
privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) },
)
ConcordStore(dataDir.concordFile).upsert(stored)
/**
* The join half shared by `join` (a link) and `accept` (a Direct Invite): [bundle] is already
* opened, bounded, owner-proof validated and not expired. Ban-gates against the community's own
* Control Plane (read over the bundle's relays, else [fallbackRelays]), stores the membership and
* announces the Guestbook Join with [inviteCreator]/[inviteLabel] attribution.
*/
private suspend fun joinBundle(
ctx: Context,
dataDir: DataDir,
bundle: CommunityInvite,
fallbackRelays: Set<NormalizedRelayUrl>,
inviteRef: String,
inviteCreator: String?,
inviteLabel: String?,
): Int {
// Refuse a link that readmits us after we were removed. A Refounding re-mints every
// outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its
// unlock token forever — so without this check the rotation that was supposed to expel
// them hands them the new keys instead. `recover` has always been ban-gated; `join` is
// the other door into the same room.
//
// Fails CLOSED on an unreadable plane: no verdict, no join. The banlist is only knowable
// after the bundle yields the root, which is why the check lives here rather than before.
val joinKeys =
ConcordActions.controlPlaneKeys(
communityRoot = bundle.communityRoot.hexToByteArray(),
communityId = bundle.communityId.hexToByteArray(),
rootEpoch = bundle.rootEpoch,
controlPk = bundle.controlPk,
)
val joinRelays = normalize(bundle.relays).ifEmpty { fallbackRelays }
val joinEditions =
ConcordActions.controlEditions(
ctx.drain(joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }, pendingOnAuthRequired = true).map { it.second },
joinKeys,
)
if (joinEditions.isEmpty()) {
return Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so whether it has banned you is unknown — refusing to join")
}
if (AuthorityResolver.resolve(joinEditions, bundle.communityId.hexToByteArray(), bundle.owner).isBanned(ctx.signer.pubKey)) {
return Output.error("banned", "this community has banned this account; the invite opens but the roster does not admit you (CORD-04)")
}
// Announce the membership (CORD-05 §6 / CORD-02 §5): a Guestbook Join is how a later
// Refounding finds this member to re-key, and it echoes the link's attribution so link
// holders can count per-link joins. Best-effort, like every Guestbook motion.
val announced = announceGuestbookJoin(ctx, stored, bundle.creatorNpub, bundle.label)
Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays, "guestbook_join" to announced))
val stored =
StoredCommunity(
name = bundle.name,
communityId = bundle.communityId,
owner = bundle.owner,
ownerSalt = bundle.ownerSalt,
root = bundle.communityRoot,
rootEpoch = bundle.rootEpoch,
// Read access to the Control Plane, never write (CORD-05 §1). Absent = the
// community is still pre-split and folds at the legacy address.
controlPk = bundle.controlPk ?: "",
relays = bundle.relays,
// The stranded-recovery anchor; blank for a Direct Invite, which has no link.
inviteRef = inviteRef,
privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) },
)
ConcordStore(dataDir.concordFile).upsert(stored)
// Announce the membership (CORD-05 §6 / CORD-02 §5): a Guestbook Join is how a later
// Refounding finds this member to re-key, and it echoes the link's attribution so link
// holders can count per-link joins. Best-effort, like every Guestbook motion.
val announced = announceGuestbookJoin(ctx, stored, inviteCreator, inviteLabel)
Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays, "guestbook_join" to announced))
return 0
}
// ---- Direct Invites (CORD-05 §6) -------------------------------------------
/**
* `concord invite COMMUNITY --to USER` — hands the community's keys straight to USER as a
* Direct Invite: the §1 bundle giftwrapped (standard NIP-59, `k=3313`) to their inbox relays.
* Which Private Channel keys ride along, and who is refused, is [ConcordActions.draftDirectInvite].
*/
private suspend fun directInvite(
dataDir: DataDir,
sc: StoredCommunity,
to: String,
expiresInSecs: Long?,
): Int {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val recipient = ctx.requireUserHex(to)
// The fold decides which Private Channel keys the recipient's Roles entitle them to and
// whether either side is banned; no fold, no verdict, no send.
val state = ConcordChannelCommands.foldState(ctx, sc)
if (state.metadata == null) {
return Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so which keys the recipient may receive is unknown — not sending")
}
val expiresAtMs = expiresInSecs?.let { TimeUtils.nowMillis() + it * 1000 }
val invite =
when (val draft = ConcordActions.draftDirectInvite(entryFor(sc), state, ctx.signer.pubKey, recipient, expiresAtMs)) {
is ConcordDirectInviteDraft.Ready -> draft.invite
is ConcordDirectInviteDraft.Refused ->
return when (draft.reason) {
ConcordDirectInviteSendResult.RECIPIENT_BANNED -> Output.error("recipient_banned", "this community has banned $recipient; their join would be refused")
ConcordDirectInviteSendResult.INVALID_RECIPIENT -> Output.error("bad_args", "'$to' is not a 32-byte pubkey").let { 2 }
else -> Output.error("not_member", "this account is banned from, or no longer holds, this community")
}
}
val wrap = ConcordActions.buildDirectInvite(ctx.signer, recipient, invite)
// Their kind-10050 DM relays, else NIP-65 read relays, else the stock set (CORD-05 §6).
val lists = ctx.cachedRelayListsOf(recipient) ?: RecipientRelayFetcher.fetchRelayLists(ctx.client, recipient, ctx.bootstrapRelays())
val relays = ConcordActions.directInviteDeliveryRelays(lists)
val ack = ctx.publish(wrap, relays)
RawEventSupport.publishGuard(ack, wrap.id)?.let { return it }
Output.emit(
mapOf(
"sent" to true,
"wrap_id" to wrap.id,
"recipient" to recipient,
"community_id" to sc.communityId,
"channels" to invite.channels.map { mapOf("id" to it.id, "name" to it.name, "epoch" to it.epoch) },
"expires_at" to invite.expiresAt,
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
/**
* Collects this account's Direct Invite wraps (`{"kinds":[1059],"#p":[me],"#k":["3313"]}`) from
* where senders deliver them — our 10050 / NIP-65 read / stock relays, plus the DM inbox — into
* the shared headless inbox, with the declines this account already made restored.
*/
private suspend fun sweepDirectInvites(
ctx: Context,
dataDir: DataDir,
): ConcordDirectInviteInbox {
val inbox = ConcordDirectInviteInbox(ctx.signer)
inbox.restoreDeclined(ConcordInviteInboxStore(dataDir.concordInvitesFile).declined())
val me = ctx.signer.pubKey
val relays = ConcordActions.directInviteDeliveryRelays(ctx.cachedRelayListsOf(me)) + ctx.inboxRelays()
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.directInvitesFilter(me)) }).map { it.second }
wraps.distinctBy { it.id }.forEach { inbox.offer(it) }
return inbox
}
private fun directInviteJson(view: ConcordDirectInviteView): Map<String, Any?> =
mapOf(
"wrap_id" to view.wrapId,
"sender" to view.sender,
"community_id" to view.communityId,
"name" to view.name,
"icon" to view.icon?.url,
"relays" to view.invite.relays,
"channels" to
view.invite.channels
.filter { it.key.isNotBlank() }
.map { mapOf("id" to it.id, "name" to it.name, "epoch" to it.epoch) },
"sent_at" to view.opened.sentAt,
"expires_at" to view.invite.expiresAt,
"expired" to view.expired,
"catch_up" to view.catchUp,
)
/** `concord invites` — the Direct Invites waiting for this account. Read-only: nothing joins. */
private suspend fun invites(
dataDir: DataDir,
rest: Array<String>,
): Int {
Args(rest).rejectUnknown()
Context.open(dataDir).use { ctx ->
ctx.prepare()
val inbox = sweepDirectInvites(ctx, dataDir)
val joined = ConcordStore(dataDir.concordFile).load().map { entryFor(it) }
val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, joined)
Output.emit(mapOf("invites" to views.map { directInviteJson(it) })) {
if (views.isEmpty()) {
"no pending direct invites"
} else {
views.joinToString(System.lineSeparator()) { v ->
val flags = listOfNotNull("expired".takeIf { v.expired }, "catch-up".takeIf { v.catchUp }).joinToString(" ") { "[$it]" }
"${v.wrapId} ${v.name.ifBlank { v.communityId.take(12) }} from ${v.sender}" + if (flags.isNotEmpty()) " $flags" else ""
}
}
}
return 0
}
}
/**
* `concord accept WRAP-ID` — accepts a Direct Invite through the same join path as a link:
* refused past `expires_at` or when the roster bans us; for a community already held, only a
* catch-up adopting newly granted Private Channel keys on the same base (never a base move).
*/
private suspend fun accept(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val ref = args.positional(0, "wrap-id").lowercase()
args.rejectUnknown()
Context.open(dataDir).use { ctx ->
ctx.prepare()
val pending = sweepDirectInvites(ctx, dataDir).pending.value.values
val opened =
pending.firstOrNull { it.wrapId == ref }
?: pending.singleOrNull { it.wrapId.startsWith(ref) }
?: return Output.error("not_found", "no pending direct invite with wrap id '$ref' (see `amy concord invites`)")
val store = ConcordStore(dataDir.concordFile)
val heldSc = store.load().firstOrNull { it.communityId.equals(opened.invite.communityId, ignoreCase = true) }
// An unreadable held plane is no verdict (metadata is written at genesis), so it waits.
val heldState = heldSc?.let { ConcordChannelCommands.foldState(ctx, it) }?.takeIf { it.metadata != null }
fun done(extra: Map<String, Any?>) = mapOf("wrap_id" to opened.wrapId, "community_id" to opened.invite.communityId, "name" to opened.invite.name) + extra
return when (val plan = ConcordDirectInviteInbox.acceptPlan(opened, heldSc?.let { entryFor(it) }, heldState, ctx.signer.pubKey)) {
DirectInviteAcceptPlan.Expired -> Output.error("expired", "this direct invite has expired and can no longer be joined")
DirectInviteAcceptPlan.Banned -> Output.error("banned", "this community has banned this account (CORD-04)")
DirectInviteAcceptPlan.RosterNotLoaded -> Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so whether it has banned you is unknown — refusing to adopt")
DirectInviteAcceptPlan.NothingNew -> {
Output.emit(done(mapOf("joined" to true, "already_member" to true, "catch_up" to false)))
0
}
is DirectInviteAcceptPlan.CatchUp -> {
val held = heldSc!!
store.upsert(storedFrom(held, plan.entry))
val added = plan.entry.privateChannels.filter { pc -> held.privateChannels.none { it.channelId.equals(pc.channelId, ignoreCase = true) && it.epoch == pc.epoch } }
Output.emit(done(mapOf("joined" to true, "catch_up" to true, "channels" to added.map { mapOf("id" to it.channelId, "name" to it.name, "epoch" to it.epoch) })))
0
}
// The Join is attributed to the seal-verified sender, never the bundle's claim.
DirectInviteAcceptPlan.Join -> joinBundle(ctx, dataDir, opened.invite, emptySet(), inviteRef = "", inviteCreator = opened.sender, inviteLabel = opened.invite.label)
}
}
}
/** `concord decline WRAP-ID` — discards a Direct Invite locally; it is never listed again. */
private fun decline(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val wrapId = args.positional(0, "wrap-id").lowercase()
args.rejectUnknown()
if (!HEX64.matches(wrapId)) return Output.error("bad_args", "expected the invite's full 64-hex wrap id, got '$wrapId'").let { 2 }
ConcordInviteInboxStore(dataDir.concordInvitesFile).decline(wrapId)
Output.emit(mapOf("declined" to wrapId))
return 0
}
// ---- shared helpers (used by ConcordChannelCommands too) ------------------
private val HEX64 = Regex("^[0-9a-f]{64}$")
@@ -0,0 +1,55 @@
/*
* 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.cli.stores
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.SecureFileIO
import java.io.File
/** amy's bookkeeping for Concord Direct Invites (CORD-05 §6): the wrap ids the user declined. */
data class StoredInviteInbox(
val declined: List<String> = emptyList(),
)
/**
* `~/.amy/<account>/concord-invites.json` — the declined Direct Invite wrap ids, so a declined
* invite (whose wrap relays keep serving until its NIP-40 expiration) never resurfaces in
* `amy concord invites`.
*/
class ConcordInviteInboxStore(
private val file: File,
) {
fun load(): StoredInviteInbox =
if (file.exists()) {
runCatching { Output.mapper.readValue<StoredInviteInbox>(file.readText()) }.getOrDefault(StoredInviteInbox())
} else {
StoredInviteInbox()
}
fun declined(): Set<String> = load().declined.toSet()
fun decline(wrapId: String) {
val current = load()
if (wrapId in current.declined) return
SecureFileIO.writeTextAtomic(file, Output.mapper.writeValueAsString(current.copy(declined = current.declined + wrapId)))
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft
import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteSendResult
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
@@ -630,6 +632,37 @@ object ConcordActions {
creatorNpub = creator,
)
/**
* The Direct Invite [sender] may hand [recipient] for the held [entry] whose Control Plane folds
* to [state] (CORD-05 §6), or why not. No community permission gates a Direct Invite — none
* could — but a dissolved community, a [sender] its roster bans (like minting a link), and a
* banned [recipient] (whose join would be refused anyway) are refused; the bundle's name/icon
* preview comes from the folded metadata.
*/
fun draftDirectInvite(
entry: ConcordCommunityListEntry,
state: ConcordCommunityState,
sender: HexKey,
recipient: HexKey,
expiresAtMs: Long? = null,
): ConcordDirectInviteDraft {
val to = recipient.lowercase()
if (!HEX64.matches(to)) return ConcordDirectInviteDraft.Refused(ConcordDirectInviteSendResult.INVALID_RECIPIENT)
if (state.dissolved || state.authority.isBanned(sender)) return ConcordDirectInviteDraft.Refused(ConcordDirectInviteSendResult.NOT_MEMBER)
if (state.authority.isBanned(to)) return ConcordDirectInviteDraft.Refused(ConcordDirectInviteSendResult.RECIPIENT_BANNED)
return ConcordDirectInviteDraft.Ready(
directInviteFor(
entry = entry,
authority = state.authority,
recipient = to,
creator = sender.lowercase(),
expiresAtMs = expiresAtMs,
name = state.metadata?.name ?: entry.name,
icon = state.metadata?.icon,
),
)
}
/** Giftwraps [invite] to [recipient] as a Direct Invite (see [ConcordDirectInvite.build]). */
suspend fun buildDirectInvite(
senderSigner: NostrSigner,
@@ -776,29 +776,20 @@ class AccountConcordActions(
): ConcordDirectInviteSendResult {
if (!account.isWriteable()) return ConcordDirectInviteSendResult.NOT_WRITEABLE
val recipient = recipientPubKey.lowercase()
if (!HEX64.matches(recipient)) return ConcordDirectInviteSendResult.INVALID_RECIPIENT
val entry =
account.concordChannelList.liveCommunities.value
.firstOrNull { it.id == communityId } ?: return ConcordDirectInviteSendResult.NOT_MEMBER
// The fold decides which Private Channel keys the recipient may receive; no fold, no send.
val state =
account.concordSessions
.sessionFor(communityId)
?.state
?.value ?: return ConcordDirectInviteSendResult.ROSTER_NOT_LOADED
if (state.dissolved) return ConcordDirectInviteSendResult.NOT_MEMBER
if (state.authority.isBanned(account.signer.pubKey)) return ConcordDirectInviteSendResult.NOT_MEMBER
if (state.authority.isBanned(recipient)) return ConcordDirectInviteSendResult.RECIPIENT_BANNED
val invite =
ConcordActions.directInviteFor(
entry = entry,
authority = state.authority,
recipient = recipient,
creator = account.signer.pubKey,
expiresAtMs = expiresAtMs,
name = state.metadata?.name ?: entry.name,
icon = state.metadata?.icon,
)
when (val draft = ConcordActions.draftDirectInvite(entry, state, account.signer.pubKey, recipient, expiresAtMs)) {
is ConcordDirectInviteDraft.Refused -> return draft.reason
is ConcordDirectInviteDraft.Ready -> draft.invite
}
val wrap = ConcordActions.buildDirectInvite(account.signer, recipient, invite)
val relays = concordDirectInviteDeliveryRelays(recipient)
if (relays.isEmpty()) return ConcordDirectInviteSendResult.NOT_DELIVERED
@@ -20,6 +20,19 @@
*/
package com.vitorpamplona.amethyst.commons.model
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
/** A Direct Invite bundle ready to wrap, or why this account may not send one (see `ConcordActions.draftDirectInvite`). */
sealed interface ConcordDirectInviteDraft {
class Ready(
val invite: CommunityInvite,
) : ConcordDirectInviteDraft
class Refused(
val reason: ConcordDirectInviteSendResult,
) : ConcordDirectInviteDraft
}
/** The outcome of sending a Concord Direct Invite (CORD-05 §6), so the UI can say why it failed. */
enum class ConcordDirectInviteSendResult {
/** At least one of the recipient's inbox relays accepted the wrap. */
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft
import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteSendResult
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
@@ -39,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@@ -125,6 +128,29 @@ class ConcordDirectInviteActionsTest {
assertEquals(entry.controlPk, toMember.controlPk)
}
@Test
fun draftRefusesBannedPartiesAndBadRecipients() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val cp = community.controlPlane
val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList()
editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, community.communityId, member.pubKey, editions, createdAt = 2L, owner = community.ownerPubKey)), cp)
val state = ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey)
val entry = entryOf(community)
fun refusal(draft: ConcordDirectInviteDraft) = (draft as? ConcordDirectInviteDraft.Refused)?.reason
assertEquals(ConcordDirectInviteSendResult.RECIPIENT_BANNED, refusal(ConcordActions.draftDirectInvite(entry, state, owner.pubKey, member.pubKey)))
assertEquals(ConcordDirectInviteSendResult.NOT_MEMBER, refusal(ConcordActions.draftDirectInvite(entry, state, member.pubKey, mod.pubKey)))
assertEquals(ConcordDirectInviteSendResult.NOT_MEMBER, refusal(ConcordActions.draftDirectInvite(entry, state.withDissolved(true), owner.pubKey, mod.pubKey)))
assertEquals(ConcordDirectInviteSendResult.INVALID_RECIPIENT, refusal(ConcordActions.draftDirectInvite(entry, state, owner.pubKey, "npub1notahexkey")))
// The folded metadata names the preview.
val ready = assertIs<ConcordDirectInviteDraft.Ready>(ConcordActions.draftDirectInvite(entry, state, owner.pubKey, mod.pubKey.uppercase()))
assertEquals("Nostrichs", ready.invite.name)
assertEquals(owner.pubKey, ready.invite.creatorNpub)
}
@Test
fun theBuiltWrapOpensForTheRecipient() =
runTest {
@@ -3617,6 +3617,23 @@
<string name="concord_create_relays">Relays</string>
<string name="concord_create_relays_desc">Relays that store this community's encrypted messages. Leave empty to use your own.</string>
<string name="concord_create_title">New Concord Channel</string>
<string name="concord_direct_invite_accept">Accept</string>
<string name="concord_direct_invite_accept_failed">Could not reach this community. Try again in a moment.</string>
<string name="concord_direct_invite_action">Invite by npub…</string>
<string name="concord_direct_invite_catch_up">New channels for a community you are in: %1$s</string>
<string name="concord_direct_invite_decline">Decline</string>
<string name="concord_direct_invite_explainer">The invite is encrypted to this person and delivered to their inbox relays. It carries only the private channels their roles give them, and it cannot be revoked once sent.</string>
<string name="concord_direct_invite_expired">This invite has expired</string>
<string name="concord_direct_invite_failed">The invite could not be delivered to this person's inbox relays.</string>
<string name="concord_direct_invite_failed_banned">This person is banned from this community.</string>
<string name="concord_direct_invite_failed_loading">This community is still loading. Try again in a moment.</string>
<string name="concord_direct_invite_failed_member">You can't invite people to this community.</string>
<string name="concord_direct_invite_from">Invited by %1$s</string>
<string name="concord_direct_invite_hint">Name, npub or NIP-05</string>
<string name="concord_direct_invite_send">Send invite to %1$s</string>
<string name="concord_direct_invite_sent">Invite sent.</string>
<string name="concord_direct_invite_title">Invite someone directly</string>
<string name="concord_direct_invites_title">Community invites</string>
<string name="concord_edit_title">Edit community</string>
<string name="concord_editing_banner">Editing message</string>
<string name="concord_home_title">Concord Channels</string>
@@ -0,0 +1,267 @@
/*
* 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.commons.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteSendResult
import com.vitorpamplona.amethyst.commons.model.ConcordInviteResult
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteView
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_accept
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_accept_failed
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_catch_up
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_decline
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_expired
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_explainer
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_failed
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_failed_banned
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_failed_loading
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_failed_member
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_from
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_hint
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_send
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_sent
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_title
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invites_title
import com.vitorpamplona.amethyst.commons.resources.concord_home_title
import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_banned
import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_expired
import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_invalid
import com.vitorpamplona.amethyst.commons.ui.components.ConcordInvitePreviewRow
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import kotlinx.coroutines.launch
/**
* "Invite by npub" (CORD-05 §6): pick a person with the app's ordinary user typeahead (cache, relay
* search, NIP-05, a pasted npub/nprofile), then hand them the community's keys as a Direct Invite —
* a giftwrap to their inbox relays carrying only the private channels their roles grant.
*/
@Composable
fun ConcordDirectInviteDialog(
communityId: String,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
val scope = rememberCoroutineScope()
var query by remember { mutableStateOf("") }
var picked by remember { mutableStateOf<User?>(null) }
var sending by remember { mutableStateOf(false) }
val userSuggestions =
remember(accountViewModel) {
UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
}
LaunchedEffect(query) { userSuggestions.processCurrentWord(query) }
AlertDialog(
onDismissRequest = { if (!sending) onDismiss() },
title = { Text(stringRes(Res.string.concord_direct_invite_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringRes(Res.string.concord_direct_invite_explainer), style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
value = query,
onValueChange = {
query = it
picked = null
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !sending,
label = { Text(stringRes(Res.string.concord_direct_invite_hint)) },
)
if (picked == null && query.length > 2) {
ShowUserSuggestionList(
userSuggestions = userSuggestions,
onSelect = { user ->
picked = user
query = user.toBestDisplayName()
},
accountViewModel = accountViewModel,
modifier = SuggestionListDefaultHeightChat,
itemColors = ListItemDefaults.colors(containerColor = Color.Transparent),
showDividers = false,
contentPadding = PaddingValues(0.dp),
)
}
}
},
confirmButton = {
val target = picked
TextButton(
enabled = target != null && !sending,
onClick = {
if (target == null) return@TextButton
sending = true
scope.launch {
try {
val result = accountViewModel.account.concord.sendConcordDirectInvite(communityId, target.pubkeyHex)
accountViewModel.toastManager.toast(Res.string.concord_direct_invite_title, sendResultMessage(result))
if (result == ConcordDirectInviteSendResult.SENT) onDismiss()
} finally {
sending = false
}
}
},
) {
Text(stringRes(Res.string.concord_direct_invite_send, picked?.toBestDisplayName() ?: "…"))
}
},
dismissButton = {
TextButton(enabled = !sending, onClick = onDismiss) { Text(stringRes(Res.string.cancel)) }
},
)
}
private fun sendResultMessage(result: ConcordDirectInviteSendResult) =
when (result) {
ConcordDirectInviteSendResult.SENT -> Res.string.concord_direct_invite_sent
ConcordDirectInviteSendResult.ROSTER_NOT_LOADED -> Res.string.concord_direct_invite_failed_loading
ConcordDirectInviteSendResult.RECIPIENT_BANNED -> Res.string.concord_direct_invite_failed_banned
ConcordDirectInviteSendResult.NOT_MEMBER, ConcordDirectInviteSendResult.NOT_WRITEABLE -> Res.string.concord_direct_invite_failed_member
ConcordDirectInviteSendResult.INVALID_RECIPIENT, ConcordDirectInviteSendResult.NOT_DELIVERED -> Res.string.concord_direct_invite_failed
}
/**
* The Direct Invites waiting for this account (CORD-05 §6), as cards with Accept / Decline — shown
* at the top of the Concord communities list. Renders nothing when there are none.
*
* Opening the hub sweeps the inbox relays once; wraps the DM pipeline sees arrive on their own.
* The preview is the bundle's own name and a robohash of the community id — **no** icon fetch, no
* relay connection to the community, no Join happens before the user taps Accept. The sender is
* shown by whatever name the cache already has, without fetching their profile.
*/
@Composable
fun ConcordPendingDirectInvites(
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
val concord = accountViewModel.account.concord
LaunchedEffect(concord) { runCatching { concord.refreshConcordDirectInvites() } }
val invites by concord.pendingConcordDirectInvites.collectAsStateWithLifecycle()
if (invites.isEmpty()) return
Column(modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringRes(Res.string.concord_direct_invites_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
invites.forEach { invite ->
ConcordDirectInviteCard(invite, accountViewModel, nav)
}
}
}
@Composable
private fun ConcordDirectInviteCard(
invite: ConcordDirectInviteView,
accountViewModel: AccountViewModel,
nav: INav,
) {
val scope = rememberCoroutineScope()
var working by remember(invite.wrapId) { mutableStateOf(false) }
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
val senderName = remember(invite.sender) { LocalCache.checkGetOrCreateUser(invite.sender)?.toBestDisplayName() ?: invite.sender.take(12) }
val subtitle =
when {
invite.expired -> stringRes(Res.string.concord_direct_invite_expired)
invite.catchUp -> stringRes(Res.string.concord_direct_invite_catch_up, invite.channelNames.joinToString(", ") { "#$it" })
else -> stringRes(Res.string.concord_direct_invite_from, senderName)
}
ElevatedCard(Modifier.fillMaxWidth()) {
ConcordInvitePreviewRow(
robotSeed = invite.communityId,
title = invite.name.ifBlank { stringRes(Res.string.concord_home_title) },
subtitle = subtitle,
accountViewModel = accountViewModel,
autoPlayGif = autoPlayGif,
)
Row(
Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.End),
) {
OutlinedButton(
enabled = !working,
onClick = { accountViewModel.account.concord.declineConcordDirectInvite(invite.wrapId) },
) {
Text(stringRes(Res.string.concord_direct_invite_decline))
}
Button(
enabled = !working && !invite.expired,
onClick = {
working = true
scope.launch {
try {
when (val result = accountViewModel.account.concord.acceptConcordDirectInvite(invite.wrapId)) {
is ConcordInviteResult.Joined -> nav.nav(Route.ConcordServer(result.communityId))
is ConcordInviteResult.Expired -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_expired)
is ConcordInviteResult.Banned -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_banned)
is ConcordInviteResult.InvalidLink -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_invalid)
else -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_direct_invite_accept_failed)
}
} finally {
working = false
}
}
},
) {
Text(stringRes(Res.string.concord_direct_invite_accept))
}
}
}
}
@@ -87,7 +87,7 @@ Ranked security > interop > feature inside each group.
| F3 | 07 | A/V calls: only key derivation, the 27235 grant and 23313 presence builders exist; no broker/SFU client, no media E2EE. Needs a LiveKit client whose license must be checked first | open — out of scope for this pass |
| F4 | 07 | Broker token has no nonce (same-second requests collide in the broker's replay set); presence fold doesn't take latest-per-author | open → chat-plane batch (quartz only) |
| F5 | 05 §5 | Invite Registry (vsk 8) not published or folded | open |
| F6 | 05 §6 | Direct invites: wire format only, no send/receive | open |
| F6 | 05 §6 | Direct invites: wire format only, no send/receive | **fixed** — wrap backdates seal/wrap ≤2 days, carries NIP-40 `expiration` = `expires_at`, `ConcordDirectInvite.open` returns the seal-verified sender and refuses rumor/seal pubkey mismatch, bad seal sig, non-3313 rumors, §1 bounds and bad owner proof; send (`sendConcordDirectInvite` / `amy concord invite --to`) vends only the private channels the recipient's channel-scoped roles grant (`ConcordInviteVend`, Armada `vendableChannels`) to their 10050 → NIP-65 read → stock relays; headless `ConcordDirectInviteInbox` (sweep via `directInvitesFilter` + the NIP-17 seal handler) dedupes by wrap id, skips expired wraps, parks invites, remembers declines; accept shares the link join path, refuses past `expires_at`, and for a held community only adopts new private-channel keys on the same root/epoch/control_pk (`catchUpChannelIds`); UI card + "Invite by npub"; `amy concord invites/accept/decline`. Not done: Armada's auto-adopt of staff-sent catch-ups (`judgeCatchUp`) and `channel_cuts` (not modeled here) |
| F7 | 06 §1-2 | Channel-scope rekeys; private-channel keys in invites | open (depends on S2) |
| F8 | 06 §2, 02 §8 | Walk forward from `seed`; we still keep intermediate roots in a `held_roots` List extension the spec says doesn't belong there | open |
| F9 | 04 §6 | Kick (kind 3309) | open |