feat(concord): manage and revoke your invite links from Android

Revoking existed only in `amy` after the last commit, so the app could hand
out a link it could never take back. This adds the Android half.

`Invite links…` in a community's overflow menu opens a screen listing every
link this account minted for it, read from the creator's own kind-13303
Invite List, each row offering Copy and Revoke. It shows only *our* links,
because a link's `signer_sk` is what authors its coordinate and only the
minting account ever held it — another admin's links are invisible here and
un-revokable from here. That is the protocol, not a gap in the screen.

Two deliberate choices:

The entry point is NOT gated on CREATE_INVITE, unlike minting. Revoking acts
on a key we hold rather than on the community, and gating it on the bit would
mean a demoted admin could no longer retire the links they had already handed
out — exactly when that matters most.

An unreadable list is its own state, never an empty one. Telling a creator
who came to kill a leaked link that they have no links would be a lie in the
one direction that costs them something.

`revokeConcordInvite` publishes the wire tombstone first and records the
kind-13303 tombstone second, for the same reason the CLI does: the entry
holds the only copy of the `signer_sk` the publish needs, and a merge drops a
tombstoned token's entry terminally. A failed list write is reported as
success because the link is already dead on the wire.

Verified on a tablet against a local relay, cross-client with amy: the screen
lists the two links the device minted (and not the one alice minted), the
confirm dialog revokes exactly one coordinate — flipping it to vsk=9 with
empty content while its siblings stay vsk=6 — the row disappears on reload,
and a link revoked from the UI is then refused by `amy concord join` with
`revoked` while the surviving link still joins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-08-10 00:57:23 -04:00
co-authored by Claude Opus 5
parent fc3f181184
commit d27930fe76
6 changed files with 377 additions and 0 deletions
@@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
@@ -339,6 +340,74 @@ class AccountConcordActions(
return minted.url
}
/**
* Every link this account minted for [communityId] that is still live, newest first — the
* backing list for the invite-links screen.
*
* Null means the list could not be read (no relay answered, or the signer refused the decrypt),
* which the UI must show as an error rather than as "you have no links": telling a creator their
* leaked link doesn't exist is worse than telling them we couldn't check.
*
* Retired tokens are filtered out here rather than rendered as dead rows — [ConcordInviteList]
* already drops a tombstoned entry on merge, so a tombstoned entry only appears in the window
* between our revoke and the next merge.
*/
suspend fun listConcordInviteLinks(communityId: String): List<ConcordInviteListEntry>? {
val list = readConcordInviteList() ?: return null
val tombstoned = list.tombstones.mapTo(HashSet()) { it.token }
return list.entries
.filter { it.communityId == communityId && it.token !in tombstoned }
.sortedByDescending { it.createdAt }
}
/**
* Retires the link [token] (CORD-05 §2): publishes a `vsk=9` tombstone at its coordinate, then
* records the retirement in the kind-13303 list. Returns false if the link could not be retired.
*
* No community permission is checked, deliberately. The coordinate is authored by the link
* signer, whose secret only the creator holds, so revoking is an act on your own key rather than
* on the community — and gating it on CREATE_INVITE would mean a demoted admin could no longer
* retire the links they had already handed out, which is precisely when they most need to.
*
* The wire tombstone goes first and the list second. That is the inverse of minting and it is
* deliberate: the entry holds the only copy of the `signer_sk` this needs, and a merge drops a
* tombstoned token's entry terminally, so recording first and then failing to publish would
* leave the link live with its signer gone and no way left to retire it. A failed list write is
* recoverable — the link is already dead on the wire, and the refresh path re-mints only a
* coordinate that still resolves Live.
*/
suspend fun revokeConcordInvite(
communityId: String,
token: String,
): Boolean {
if (!account.isWriteable()) return false
val entry =
account.concordChannelList.liveCommunities.value
.firstOrNull { it.id == communityId } ?: return false
val link =
readConcordInviteList()?.entries?.firstOrNull { it.token == token && it.communityId == communityId }
?: run {
Log.w("Concord") { "Cannot revoke $token: it is not in this account's invite list, so its link signer is unknown" }
return false
}
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value }
if (relays.isEmpty()) return false
val published =
runCatching {
account.client.publish(ConcordActions.revokeBundleAt(link.signerSk.hexToByteArray(), TimeUtils.now()), relays)
true
}.onFailure { Log.w("Concord", "invite revocation failed for $communityId", it) }.getOrDefault(false)
if (!published) return false
if (!publishConcordInviteList(ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = communityId))))) {
// The link is already dead on the wire, so this is bookkeeping we can retry rather than a
// failed revocation. Reported as success for exactly that reason.
Log.w("Concord") { "Revoked $token on the wire but could not tombstone it in the invite list; a later revoke will record it" }
}
return true
}
/** Drop a joined Concord community from the private kind-13302 list by its id. */
suspend fun leaveConcordCommunity(communityId: String) = account.sendMyPublicAndPrivateOutbox(account.concordChannelList.unfollow(communityId))
@@ -138,6 +138,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCreateScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordEditScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordHomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteLinksScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordMembersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen
@@ -747,6 +748,14 @@ fun BuildNavigation(
)
}
composableFromEndArgs<Route.ConcordInviteLinks> {
ConcordInviteLinksScreen(
communityId = it.communityId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.ConcordEdit> {
ConcordEditScreen(
communityId = it.communityId,
@@ -830,6 +830,10 @@ sealed class Route {
val communityId: String,
) : Route()
@Serializable data class ConcordInviteLinks(
val communityId: String,
) : Route()
@Serializable object ConcordCreate : Route()
// Deep-link target for a Concord invite link (naddr#fragment). Opens the join flow.
@@ -294,6 +294,17 @@ fun ConcordChannelListScreen(
SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.more_options))
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
// 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
// links they had already handed out — exactly when that matters most.
DropdownMenuItem(
text = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_links_action)) },
onClick = {
menuOpen = false
nav.nav(Route.ConcordInviteLinks(communityId))
},
)
DropdownMenuItem(
text = {
Text(
@@ -0,0 +1,273 @@
/*
* 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.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.platform.LocalClipboard
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
import kotlinx.coroutines.launch
import java.text.DateFormat
import java.util.Date
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/** What the screen is currently showing. The unreadable case is deliberately not "empty" — see below. */
private sealed interface LinksState {
data object Loading : LinksState
data class Loaded(
val links: List<ConcordInviteListEntry>,
) : LinksState
/**
* The kind-13303 list could not be read. Distinct from an empty list on purpose: rendering
* "no links yet" here would tell a creator that the link they came to kill does not exist.
*/
data object Unreadable : LinksState
}
/**
* Every invite link this account minted for one community, with the ability to retire one
* (CORD-05 §2).
*
* The list is the creator's own kind-13303 Invite List, which is where a link's `signer_sk` lives
* so this shows only links *this account* minted, from any of its devices. Another admin's links are
* invisible here and un-revokable from here, because the secret that authors their coordinate was
* never ours. That is a property of the protocol, not a gap in the screen.
*
* Fetched on entry rather than collected from a flow: nothing subscribes to kind 13303 (it is
* bookkeeping the user never sees), so there is no cache to observe.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConcordInviteLinksScreen(
communityId: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val account = accountViewModel.account
val scope = rememberCoroutineScope()
val clipboard = LocalClipboard.current
var state by remember(communityId) { mutableStateOf<LinksState>(LinksState.Loading) }
var reloads by remember(communityId) { mutableIntStateOf(0) }
var confirming by remember { mutableStateOf<ConcordInviteListEntry?>(null) }
var revoking by remember { mutableStateOf(false) }
LaunchedEffect(communityId, reloads) {
state = LinksState.Loading
state = account.concord.listConcordInviteLinks(communityId)?.let { LinksState.Loaded(it) } ?: LinksState.Unreadable
}
val communityName =
remember(account, communityId) {
account.concordChannelList.liveCommunities.value
.firstOrNull { it.id == communityId }
?.name
.orEmpty()
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(stringRes(R.string.concord_invite_links_title), fontWeight = FontWeight.Bold)
if (communityName.isNotBlank()) {
Text(communityName, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
},
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back))
}
},
)
},
) { padding ->
when (val current = state) {
is LinksState.Loading ->
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
is LinksState.Unreadable -> CenteredMessage(padding, stringRes(R.string.concord_invite_links_unreadable))
is LinksState.Loaded ->
if (current.links.isEmpty()) {
CenteredMessage(padding, stringRes(R.string.concord_invite_links_empty))
} else {
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(current.links, key = { it.token }) { link ->
InviteLinkRow(
link = link,
enabled = !revoking,
onCopy = { scope.launch { clipboard.setText(link.url) } },
onRevoke = { confirming = link },
)
HorizontalDivider()
}
}
}
}
}
confirming?.let { link ->
AlertDialog(
onDismissRequest = { if (!revoking) confirming = null },
title = { Text(stringRes(R.string.concord_invite_revoke_title)) },
text = { Text(stringRes(R.string.concord_invite_revoke_explainer)) },
confirmButton = {
TextButton(
enabled = !revoking,
onClick = {
revoking = true
scope.launch {
try {
val ok = account.concord.revokeConcordInvite(communityId, link.token)
accountViewModel.toastManager.toast(
R.string.concord_invite_links_title,
if (ok) R.string.concord_invite_revoked_ok else R.string.concord_invite_revoked_failed,
)
// Re-read either way: on success the link is gone from the list, and on
// failure the list is the only thing that can say whether it changed.
reloads++
} finally {
revoking = false
confirming = null
}
}
},
) {
Text(stringRes(R.string.concord_invite_revoke_confirm), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(enabled = !revoking, onClick = { confirming = null }) {
Text(stringRes(R.string.cancel))
}
},
)
}
}
@Composable
private fun CenteredMessage(
padding: PaddingValues,
message: String,
) {
Box(Modifier.fillMaxSize().padding(padding).padding(24.dp), contentAlignment = Alignment.Center) {
Text(
message,
// This Box sits on the bare window background, so LocalContentColor is still the M3
// default black — see the sibling invite screen, where that made the text invisible.
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.bodyLarge,
)
}
}
@Composable
private fun InviteLinkRow(
link: ConcordInviteListEntry,
enabled: Boolean,
onCopy: () -> Unit,
onRevoke: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f).padding(end = 8.dp)) {
// The token prefix is what tells two links to the same community apart; their URLs share
// a long prefix, so they are useless as labels until well past where the row wraps.
Text(link.token.take(8), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge)
Text(
stringRes(R.string.concord_invite_links_created, DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(link.createdAt * 1000))),
style = MaterialTheme.typography.bodySmall,
)
Text(link.url, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
IconButton(enabled = enabled, onClick = { menuOpen = true }) {
SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(R.string.more_options))
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_to_clipboard)) },
onClick = {
menuOpen = false
onCopy()
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.concord_invite_revoke_action), color = MaterialTheme.colorScheme.error) },
onClick = {
menuOpen = false
onRevoke()
},
)
}
}
}
+11
View File
@@ -322,6 +322,17 @@
<string name="concord_invite_failed_incompatible">This invite link can\'t be opened. It may be outdated or already replaced by a newer one, or created with a newer version of the app. Ask for a fresh invite link.</string>
<string name="concord_invite_failed_revoked">This invite link has been revoked and can no longer be used. Ask for a new one.</string>
<string name="concord_invite_failed_banned">This community has removed you. The link still works, but its member list does not admit you.</string>
<string name="concord_invite_links_title">Invite links</string>
<string name="concord_invite_links_action">Invite links…</string>
<string name="concord_invite_links_created">Created %1$s</string>
<string name="concord_invite_links_empty">You haven\'t created any invite links for this community yet. Links other admins created are managed on their own devices.</string>
<string name="concord_invite_links_unreadable">Your invite links couldn\'t be loaded, so none can be revoked right now. Check your connection and try again.</string>
<string name="concord_invite_revoke_action">Revoke link</string>
<string name="concord_invite_revoke_title">Revoke this link?</string>
<string name="concord_invite_revoke_explainer">Anyone still holding this link will no longer be able to join. People who already joined with it stay in the community. This can\'t be undone.</string>
<string name="concord_invite_revoke_confirm">Revoke</string>
<string name="concord_invite_revoked_ok">Invite link revoked.</string>
<string name="concord_invite_revoked_failed">The link couldn\'t be revoked. Check your connection and try again.</string>
<string name="concord_invite_failed_expired">This invite link has expired and can no longer be used. Ask for a fresh link.</string>
<string name="concord_invite_preview_unknown_name">Community name is only revealed after you join</string>
<string name="concord_invite_preview_explainer">Joining connects to this invite\'s relays, publishes a join announcement signed by your account, and adds the community to your list. Nothing is sent until you tap Join.</string>