diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 9f67f7629f..9c2a42b63e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -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 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 62045385f4..d7ba2251db 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 @@ -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 diff --git a/cli/README.md b/cli/README.md index 519e277554..cf8a7a3dc8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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/ └── … diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index efc4c20f40..95dd68e291 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -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") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 89f9929c45..87caabad5f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -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 | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt index 312c3b88d3..554e549582 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt @@ -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 { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index dd7a23c1f4..683bcb54d2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -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 ", + "concord ", 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, + 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 = + 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, + ): 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, + ): 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) = 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, + ): 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}$") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordInviteInboxStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordInviteInboxStore.kt new file mode 100644 index 0000000000..b9ec3a7116 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordInviteInboxStore.kt @@ -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 = emptyList(), +) + +/** + * `~/.amy//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(file.readText()) }.getOrDefault(StoredInviteInbox()) + } else { + StoredInviteInbox() + } + + fun declined(): Set = 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))) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 86f7a9d918..ebc616bf87 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -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, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index e844c8b791..5a2d0a93dd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt index d82eb09e5c..81f93237a7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt @@ -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. */ diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt index 05296629f5..63ff78adc8 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt @@ -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(ConcordActions.draftDirectInvite(entry, state, owner.pubKey, mod.pubKey.uppercase())) + assertEquals("Nostrichs", ready.invite.name) + assertEquals(owner.pubKey, ready.invite.creatorNpub) + } + @Test fun theBuiltWrapOpensForTheRecipient() = runTest { diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 1003a3ee26..76fbbd37c8 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -3617,6 +3617,23 @@ Relays Relays that store this community's encrypted messages. Leave empty to use your own. New Concord Channel + Accept + Could not reach this community. Try again in a moment. + Invite by npub… + New channels for a community you are in: %1$s + Decline + 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. + This invite has expired + The invite could not be delivered to this person's inbox relays. + This person is banned from this community. + This community is still loading. Try again in a moment. + You can't invite people to this community. + Invited by %1$s + Name, npub or NIP-05 + Send invite to %1$s + Invite sent. + Invite someone directly + Community invites Edit community Editing message Concord Channels diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt new file mode 100644 index 0000000000..e373ccb0f7 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt @@ -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(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)) + } + } + } +} diff --git a/quartz/plans/2026-09-29-concord-spec-conformance.md b/quartz/plans/2026-09-29-concord-spec-conformance.md index b4a9ec4eb9..a4dd16ec67 100644 --- a/quartz/plans/2026-09-29-concord-spec-conformance.md +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -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 |