mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
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:
@@ -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 ───────────────────────────────────────────
|
||||
|
||||
+9
@@ -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()) {
|
||||
|
||||
+50
-19
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -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).
|
||||
|
||||
+15
@@ -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"))
|
||||
|
||||
+53
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user