mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
co-authored by
Claude Opus 5
parent
fc3f181184
commit
d27930fe76
@@ -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.
|
||||
|
||||
+11
@@ -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(
|
||||
|
||||
+273
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user