fix(concord): show the 64-byte name cap instead of silently dropping the edit

Community, channel and access-role names are capped at 64 UTF-8 bytes (CORD-02
§6). The actions refused a longer name, but the forms took any length, so a
long rename or channel create simply did nothing.

The name fields now show a "used / 64 bytes" counter that turns red past the
cap, and Create / Save / Rename stay disabled until the name fits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-29 20:32:10 -04:00
co-authored by Claude Opus 5.5
parent 1a821544f5
commit 88149f66f3
5 changed files with 29 additions and 4 deletions
@@ -591,6 +591,7 @@
<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_revoking">Revoking…</string>
<string name="concord_name_budget">%1$d / %2$d bytes</string>
<string name="concord_invite_revoke_privatize_warning">This is the community's last live invite link. Revoking it makes the community Private and rotates its keys, so only current members keep access.</string>
<string name="concord_invite_revoked_privatized">Link revoked. The community is now Private and its keys were rotated.</string>
<string name="concord_invite_revoked_privatize_pending">Link revoked. The community is now Private, but its keys were not rotated: an admin who can ban members needs to rotate them.</string>
@@ -851,6 +851,8 @@ private fun ConcordChannelEditDialog(
onValueChange = { name = it },
singleLine = true,
label = { Text(stringRes(Res.string.concord_channel_name_label)) },
isError = !concordNameFits(name),
supportingText = { ConcordNameBudget(name) },
modifier = Modifier.fillMaxWidth(),
)
// A new channel may be Private (CORD-03): its own key, and an access Role — the
@@ -872,6 +874,8 @@ private fun ConcordChannelEditDialog(
singleLine = true,
placeholder = { Text(name.trim()) },
label = { Text(stringRes(Res.string.concord_channel_access_role_label)) },
isError = !concordNameFits(roleName),
supportingText = { if (roleName.isNotBlank()) ConcordNameBudget(roleName) },
modifier = Modifier.fillMaxWidth(),
)
}
@@ -880,8 +884,9 @@ private fun ConcordChannelEditDialog(
},
confirmButton = {
TextButton(
enabled = name.isNotBlank(),
onClick = { if (name.isNotBlank()) onConfirm(name.trim(), makePrivate, roleName.trim().ifBlank { null }) },
// Past the 64-byte cap every reader drops the edition, so the action would silently no-op.
enabled = name.isNotBlank() && concordNameFits(name) && concordNameFits(roleName),
onClick = { if (name.isNotBlank() && concordNameFits(name) && concordNameFits(roleName)) onConfirm(name.trim(), makePrivate, roleName.trim().ifBlank { null }) },
) {
Text(
stringRes(
@@ -147,7 +147,7 @@ fun ConcordCreateScreen(
}
}
},
enabled = name.value.isNotBlank() && !working,
enabled = name.value.isNotBlank() && concordNameFits(name.value) && !working,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Text(stringRes(Res.string.concord_create_action))
@@ -202,7 +202,7 @@ fun ConcordEditScreen(
if (ok) nav.popBack()
}
},
enabled = name.value.isNotBlank() && !working,
enabled = name.value.isNotBlank() && concordNameFits(name.value) && !working,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Text(stringRes(Res.string.concord_edit_save))
@@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.commons.resources.concord_create_icon_hint
import com.vitorpamplona.amethyst.commons.resources.concord_create_name
import com.vitorpamplona.amethyst.commons.resources.concord_create_title
import com.vitorpamplona.amethyst.commons.resources.concord_edit_banner_hint
import com.vitorpamplona.amethyst.commons.resources.concord_name_budget
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_details
import com.vitorpamplona.amethyst.commons.resources.remove
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelectSingle
@@ -85,6 +86,7 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.MediumRelayIconModifier
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.utils.Log
@@ -130,6 +132,8 @@ fun ConcordMetadataFields(
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(stringRes(Res.string.concord_create_name)) },
isError = !concordNameFits(name.value),
supportingText = { ConcordNameBudget(name.value) },
)
OutlinedTextField(
value = about.value,
@@ -142,6 +146,21 @@ fun ConcordMetadataFields(
}
}
/**
* Whether [name] fits the protocol-wide name cap (CORD-02 §6: 64 UTF-8 bytes, for the Community,
* Channels and Roles). Every reader drops an edition past it, so a form must not submit one.
*/
fun concordNameFits(name: String): Boolean = ConcordLimits.nameFits(name.trim())
/** A text field's `used / 64` byte counter for a Concord name, in the error color once over the cap. */
@Composable
fun ConcordNameBudget(name: String) {
Text(
stringRes(Res.string.concord_name_budget, ConcordLimits.utf8Size(name.trim()), ConcordLimits.NAME_MAX_BYTES),
color = if (concordNameFits(name)) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
)
}
/**
* The circular community-icon hero: shows the current (decrypted) icon over a stable robohash
* placeholder, and on tap opens the photo picker → encrypts + uploads the chosen image and updates