feat(concord): plane-wrapped reactions and replies (outbound)

Receiving reactions/replies already worked (they decrypt via channelRumors and
wire to their target Note by id). This adds the send side, which must NOT go
through the generic public/NIP-17 reaction path — a plaintext kind-7 would e-tag
the private rumor id onto public relays, and the NIP-17 path wraps to named
recipients, not the channel plane.

- ChannelChat.reaction/reply build kind-7/kind-9 rumors bound to channel+epoch
  (reaction e-tags the target; reply q-tags the parent)
- ConcordActions.buildChannelReaction/buildChannelReply wrap them on the plane
- Account.reactToConcordMessage + sendConcordChannelMessage(replyTo) publish the
  wrap with an instant local echo, factored through publishConcordWrap
- AccountViewModel.reactToOrDelete intercepts Concord notes (detected by the
  ConcordChannel gatherer) and routes to the plane-wrapped reaction
- ConcordChannelScreen wires onWantsToReply into the composer with a reply banner

Zaps already route correctly: a Concord message is an unsigned rumor, so the
existing isPrivateRumor() path forces a PRIVATE (NIP-57 encrypted) zap, same as
NIP-17 DMs. A fully on-plane nutzap (no public receipt) remains a future upgrade.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-10 21:20:07 +00:00
parent 65502c7cda
commit e85de73e60
6 changed files with 197 additions and 22 deletions
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState
import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
@@ -1598,19 +1599,57 @@ class Account(
communityId: String,
channelIdHex: String,
text: String,
replyTo: Note? = null,
): Boolean {
if (!isWriteable()) return false
val session = concordSessions.sessionFor(communityId) ?: return false
val entry = session.entry
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
val parent = replyTo?.event
val wrap =
if (parent != null) {
ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now())
} else {
ConcordActions.buildChannelMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now())
}
publishConcordWrap(entry, wrap)
return true
}
/**
* React to a Concord message with [reaction] (e.g. `"+"`, an emoji). Mirrors
* [sendConcordChannelMessage]: builds a kind-7 rumor bound to the message's
* channel/epoch, wraps it on the plane, and publishes it — so the reaction stays
* inside the encrypted channel (never a plaintext public kind-7 that would leak
* the message id). [note] must be a Concord channel message (carries a
* [ConcordChannel] gatherer).
*/
suspend fun reactToConcordMessage(
note: Note,
reaction: String,
): Boolean {
if (!isWriteable()) return false
val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false
val target = note.event ?: return false
val communityId = channel.channelId.communityId
val channelIdHex = channel.channelId.channelId
val entry = concordSessions.sessionFor(communityId)?.entry ?: return false
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
val wrap = ConcordActions.buildChannelMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now())
val wrap = ConcordActions.buildChannelReaction(signer, channelKey, channelIdHex, entry.rootEpoch, target, reaction, TimeUtils.now())
publishConcordWrap(entry, wrap)
return true
}
// Instant local echo, then publish to every relay the community lives on.
/** Instant local echo (the session folds it back as a Note) + publish to the community relays. */
private fun publishConcordWrap(
entry: ConcordCommunityListEntry,
wrap: Event,
) {
concordSessions.ingest(wrap)
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
if (relays.isNotEmpty()) client.publish(wrap, relays)
return true
}
// ── NIP-29 relay-group actions ───────────────────────────────────────────
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
@@ -521,6 +522,14 @@ class AccountViewModel(
note: Note,
reaction: String,
) {
// Concord messages are encrypted: a public kind-7 would e-tag the private rumor id onto
// public relays. Route the reaction through a channel-plane wrap instead. (Retraction of an
// existing Concord reaction is a follow-up; for now this only adds one.)
if (note.inGatherers?.any { it is ConcordChannel } == true) {
launchSigner { account.reactToConcordMessage(note, reaction) }
return
}
launchSigner {
val currentReactions = note.allReactionsOfContentByAuthor(userProfile(), reaction)
if (currentReactions.isNotEmpty()) {
@@ -47,6 +47,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -92,6 +94,7 @@ fun ConcordChannelScreen(
val scope = rememberCoroutineScope()
var draft by remember { mutableStateOf("") }
var replyTo by remember { mutableStateOf<Note?>(null) }
Scaffold(
topBar = {
@@ -119,7 +122,7 @@ fun ConcordChannelScreen(
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "Concord/$communityId/$channelId",
onWantsToReply = {},
onWantsToReply = { replyTo = it },
onWantsToEditDraft = {},
)
}
@@ -127,12 +130,17 @@ fun ConcordChannelScreen(
if (channel.canPost()) {
ConcordComposer(
draft = draft,
replyingTo = replyTo,
accountViewModel = accountViewModel,
onDraftChange = { draft = it },
onCancelReply = { replyTo = null },
onSend = {
val text = draft.trim()
if (text.isNotEmpty()) {
val parent = replyTo
draft = ""
scope.launch { account.sendConcordChannelMessage(communityId, channelId, text) }
replyTo = null
scope.launch { account.sendConcordChannelMessage(communityId, channelId, text, parent) }
}
},
)
@@ -144,27 +152,50 @@ fun ConcordChannelScreen(
@Composable
private fun ConcordComposer(
draft: String,
replyingTo: Note?,
accountViewModel: AccountViewModel,
onDraftChange: (String) -> Unit,
onCancelReply: () -> Unit,
onSend: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = draft,
onValueChange = onDraftChange,
modifier = Modifier.weight(1f),
placeholder = { Text(stringRes(com.vitorpamplona.amethyst.R.string.reply_here)) },
maxLines = 5,
)
Box(Modifier.padding(start = 6.dp)) {
IconButton(onClick = onSend, enabled = draft.isNotBlank()) {
SymbolIcon(
symbol = MaterialSymbols.AutoMirrored.Send,
contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.send),
tint = MaterialTheme.colorScheme.primary,
Column(Modifier.fillMaxWidth()) {
if (replyingTo != null) {
val name by observeUserName(remember(replyingTo) { replyingTo.author ?: LocalCache.getOrCreateUser(replyingTo.event?.pubKey ?: "") }, accountViewModel)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "↰ $name: ${replyingTo.event?.content?.take(80).orEmpty()}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
maxLines = 1,
)
IconButton(onClick = onCancelReply) {
SymbolIcon(symbol = MaterialSymbols.Close, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.cancel))
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = draft,
onValueChange = onDraftChange,
modifier = Modifier.weight(1f),
placeholder = { Text(stringRes(com.vitorpamplona.amethyst.R.string.reply_here)) },
maxLines = 5,
)
Box(Modifier.padding(start = 6.dp)) {
IconButton(onClick = onSend, enabled = draft.isNotBlank()) {
SymbolIcon(
symbol = MaterialSymbols.AutoMirrored.Send,
contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.send),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
@@ -129,6 +129,34 @@ object ConcordActions {
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
}
/** Builds an encrypted-seal reply wrap (kind 9 quoting [parent]) on the [channel] plane. */
suspend fun buildChannelReply(
authorSigner: NostrSigner,
channel: GroupKey,
channelId: HexKey,
epoch: Long,
parent: Event,
text: String,
createdAt: Long,
): Event {
val rumor = ChannelChat.reply(authorSigner.pubKey, channelId, epoch, text, parent.id, parent.pubKey, createdAt)
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
}
/** Builds an encrypted-seal reaction wrap (kind 7 against [target]) on the [channel] plane. */
suspend fun buildChannelReaction(
authorSigner: NostrSigner,
channel: GroupKey,
channelId: HexKey,
epoch: Long,
target: Event,
reaction: String,
createdAt: Long,
): Event {
val rumor = ChannelChat.reaction(authorSigner.pubKey, channelId, epoch, target.id, target.pubKey, target.kind, reaction, createdAt)
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
}
/**
* Opens the channel [wraps], keeps the kind-9 messages correctly bound to
* [channelId]/[epoch], and returns them oldest-first (createdAt, then id).
@@ -71,6 +71,21 @@ class ConcordCommunitySessionTest {
assertEquals(1, general9.size)
assertEquals(community.communityIdHex, general9[0].first)
assertEquals(owner.pubKey, general9[0].third.pubKey)
val message = general9[0].third
// A reaction to that message decrypts as a kind-7 bound to the channel, e-tagging the target.
val reactionWrap = ConcordActions.buildChannelReaction(owner, general, community.generalChannelIdHex, community.rootEpoch, message, "🤙", 3L)
assertTrue(session.ingest(reactionWrap))
val reaction = captured.map { it.third }.first { it.kind == 7 }
assertEquals("🤙", reaction.content)
assertEquals(message.id, reaction.tags.first { it[0] == "e" }[1])
// A reply decrypts as a kind-9 quoting the parent via a `q` tag.
val replyWrap = ConcordActions.buildChannelReply(owner, general, community.generalChannelIdHex, community.rootEpoch, message, "gm back", 4L)
assertTrue(session.ingest(replyWrap))
val reply = captured.map { it.third }.first { it.content == "gm back" }
assertEquals(9, reply.kind)
assertEquals(message.id, reply.tags.first { it[0] == "q" }[1])
// A stray wrap from a different community is ignored.
val outsider = ConcordCommunityFactory.create(owner, "Other", createdAt = 1L, relays = listOf("wss://r.example"))
@@ -67,6 +67,59 @@ object ChannelChat {
content = text,
)
/**
* Builds an unsigned kind-9 reply rumor bound to [channelId]/[epoch], quoting
* [parentId] (a `q` tag, NIP-C7 style) and crediting its author with a `p` tag.
* Reuses [message], so it is a normal channel message that also threads.
*/
fun reply(
authorPubKey: HexKey,
channelId: HexKey,
epoch: Long,
text: String,
parentId: HexKey,
parentAuthor: HexKey,
createdAt: Long,
): Event =
message(
authorPubKey = authorPubKey,
channelId = channelId,
epoch = epoch,
text = text,
createdAt = createdAt,
extraTags = arrayOf(arrayOf("q", parentId), arrayOf("p", parentAuthor)),
)
/**
* Builds an unsigned kind-7 reaction rumor bound to [channelId]/[epoch] against
* the target message ([targetId]/[targetAuthor]/[targetKind]). [content] is the
* reaction (e.g. `"+"`, `"🤙"`). On the receiving side this decrypts to a normal
* kind-7 that wires to its target Note by the `e` tag through the shared cache.
*/
fun reaction(
authorPubKey: HexKey,
channelId: HexKey,
epoch: Long,
targetId: HexKey,
targetAuthor: HexKey,
targetKind: Int,
content: String,
createdAt: Long,
): Event =
RumorAssembler.assembleRumor(
pubKey = authorPubKey,
createdAt = createdAt,
kind = ConcordKinds.REACTION,
tags =
bindingTags(channelId, epoch) +
arrayOf(
arrayOf("e", targetId),
arrayOf("p", targetAuthor),
arrayOf("k", targetKind.toString()),
),
content = content,
)
/** The channel id a Chat Plane [rumor] is bound to, or null if unbound. */
fun channelOf(rumor: Event): HexKey? = rumor.tags.firstTagValue(TAG_CHANNEL)